Compare commits

...
30 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub 3f42666bf6 Update go.mod and go.sum (#18) 2026-05-01 05:25:29 +01:00
lukaszraczyloandGitHub 501711a743 Update go.mod and go.sum (#17) 2026-04-28 05:17:42 +01:00
lukaszraczyloandGitHub 16bcfdcb86 Update go.mod and go.sum (#16) 2026-04-22 05:03:15 +01:00
lukaszraczyloandGitHub ad696a51d8 feat(release): drop darwin_amd64 (Intel Macs) (#15)
Intel Macs are no longer receiving macOS updates. Drop from release
matrix and re-add to goreleaser ignore list. install.sh now prints
a clear message pointing Intel users to build-from-source. README
manual-install section no longer advertises a darwin_amd64 URL.
2026-04-19 23:48:25 +01:00
lukaszraczyloandGitHub 1ef24994ca Fix/release split by target (#14)
* fix(release): split partial by target to avoid CGO cross-compile

goreleaser --split defaults to splitting by GOOS only, so on a
linux_amd64 runner it tried to build both linux/amd64 and linux/arm64
CGO binaries. The amd64 host gcc cannot assemble ARM64 runtime/cgo
(gcc_arm64.S: no such instruction: stp, ldp, blr).

Setting partial.by: target makes goreleaser split by the full
goos+goarch tuple. Each matrix runner then builds only the artifact
matching its own architecture — native CGO compile, no cross-toolchain
needed.

* feat(release): ship raw binaries instead of tar.gz/zip archives

Users want curl-and-run, not 'download archive, unpack, locate binary'.
Switched archives.formats from tar.gz (with zip override for Windows)
to binary. goreleaser now emits:

  mcp-filepuff_<v>_darwin_amd64
  mcp-filepuff_<v>_darwin_arm64
  mcp-filepuff_<v>_linux_amd64
  mcp-filepuff_<v>_linux_arm64
  mcp-filepuff_<v>_windows_amd64.exe

Dropped the LICENSE+README bundling — tree-view on the release page
still shows them, and no one was extracting them from tarballs to
read anyway.

* docs,install: drop tarball indirection; download raw binary

Releases now ship raw binaries (goreleaser archives.formats=binary).
install.sh and README manual-install blurb now curl the binary
directly, chmod +x, and move into place. No tar, no zip, no extract.

Also removes the Linux ARM64 hard-reject in install.sh — that platform
is now shipped from the ubuntu-24.04-arm matrix runner.
2026-04-19 23:01:44 +01:00
lukaszraczyloandGitHub f1643e7b81 Fix/release drop broken static (#13)
* fix(release): drop broken CGO_ENABLED=0 static build target

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

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

* feat(release): ship darwin_amd64 and linux_arm64 binaries

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

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

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

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

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

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

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

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

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

The flag does not exist in stable ripgrep (confirmed up to 15.1.0 --
"unrecognized flag --max-total-count, similar flags that are
available: --max-count"). Every file_search call failed on hosts with
stock rg. --max-count is per-file, not a drop-in replacement, so rely
on the in-process truncation in parseOutput that was already the
documented safety net.
2026-04-19 19:56:49 +01:00
lukaszraczyloandGitHub b131c1edd3 Update go.mod and go.sum (#10) 2026-04-15 05:02:41 +01:00
lukaszraczyloandGitHub 7829ae4891 Update go.mod and go.sum (#9) 2026-04-09 04:58:25 +01:00
lukaszraczyloandGitHub 0d91e94416 Update go.mod and go.sum (#8) 2026-04-05 05:01:32 +01:00
lukaszraczyloandGitHub 7ad20c33f9 Update go.mod and go.sum (#7) 2026-03-27 03:58:58 +00:00
lukaszraczyloandGitHub 90244fda67 Update go.mod and go.sum (#6) 2026-03-20 03:48:30 +00:00
lukaszraczyloandGitHub 9a9d81f2a4 Update go.mod and go.sum (#5) 2026-03-15 03:59:02 +00:00
lukaszraczyloandGitHub 78c5fc7236 Update go.mod and go.sum (#4) 2026-03-13 03:48:53 +00:00
lukaszraczylo ccfbdc513f fixup! fixup! fixup! fixup! Add Docker usage instructions to README 2026-03-12 19:21:13 +00:00
lukaszraczylo db803c6950 fixup! fixup! fixup! Add Docker usage instructions to README 2026-03-12 10:42:39 +00:00
lukaszraczylo 34d4401280 fixup! fixup! Add Docker usage instructions to README 2026-03-11 13:18:59 +00:00
lukaszraczylo 267378181c fixup! Add Docker usage instructions to README 2026-03-11 02:53:12 +00:00
lukaszraczylo 2b83fccb35 Add Docker usage instructions to README 2026-03-07 18:55:05 +00:00
lukaszraczylo 996be39314 Add Docker image support with distroless container
- Dockerfile: distroless base (glibc) for CGO binary
- GoReleaser: Docker build + push to ghcr.io/lukaszraczylo/filepuff-mcp
- Cosign signing for Docker manifests
2026-03-07 18:47:22 +00:00
lukaszraczyloandGitHub 50da24756f Update go.mod and go.sum (#3) 2026-03-07 03:42:02 +00:00
lukaszraczyloandGitHub a778e0b28b Update go.mod and go.sum (#2) 2026-02-28 03:37:42 +00:00
lukaszraczylo e2dc4a1f40 multi-fixes 2026-02-22 15:24:48 +00:00
lukaszraczylo 982c2c8b44 fixup! Update, bugfixes on diff and edit handling 2026-02-22 14:03:54 +00:00
lukaszraczylo 6980d3b294 Update, bugfixes on diff and edit handling 2026-02-18 21:49:55 +00:00
lukaszraczylo 9205b2bc26 feat(docs, ci, config): add comprehensive documentation and tooling
- [x] Add API reference documentation with tool descriptions and examples
- [x] Add ERROR_CODES reference with error descriptions and remediation steps
- [x] Add PERFORMANCE tuning guide with caching and optimization details
- [x] Add GitHub Actions workflows for linting and security scanning
- [x] Add golangci-lint configuration with comprehensive linter settings
- [x] Add pre-commit hooks configuration for local development
- [x] Add API documentation generator tool (cmd/docgen)
- [x] Update Go version from 1.24 to 1.25 across workflows
- [x] Add static build configuration to goreleaser
- [x] Add metrics package with Prometheus-style metric types
- [x] Add parser benchmarks for performance testing
- [x] Add LSP manager integration tests
- [x] Add server integration tests with MCP protocol flow testing
- [x] Extract regex cache to shared utility package
- [x] Add context cancellation handling in AST queries
- [x] Add graceful shutdown with timeout to server
- [x] Add configurable max parse size (MaxParseSize)
- [x] Add Config.Validate() method with comprehensive checks
- [x] Add parser cache statistics tracking
- [x] Add file permission preservation in edit operations
- [x] Improve line splitting for large files with bufio.Scanner
- [x] Add comprehensive config tests for edge cases
- [x] Update Makefile with new targets and documentation
2026-02-18 21:49:54 +00:00
lukaszraczyloandGitHub 143a166249 Update go.mod and go.sum (#1) 2026-02-16 03:53:38 +00:00
lukaszraczylo 9d84c1253b test(edit): add comprehensive selector specificity tests
- [x] Add sortBySpecificity and shouldPrefer helper functions for node preference logic
- [x] Implement isDeclarationLike pattern matching for declaration/statement node types
- [x] Add AtLine selector specificity logic to prefer smallest meaningful nodes
- [x] Add TestResolveSelectorAtLineSpecificity to verify correct node selection
- [x] Add TestRegressionInsertAfterAtLine to prevent file corruption on insertions
- [x] Add TestRegressionInsertBeforeAtLine to verify insert-before ordering
- [x] Add TestRegressionNestedStructures to ensure nested nodes select correctly
- [x] Add TestRegressionPreservesFileIntegrity to verify unrelated content preservation
- [x] Add TestRegressionMultipleConstBlocks for multi-block const handling
- [x] Add TestSortBySpecificity unit test for sorting logic
2026-01-23 20:56:26 +00:00
lukaszraczylo b8d868115c feat(parser): add Elixir language support
- [x] Add Elixir documentation extraction (@doc and @moduledoc attributes)
- [x] Add Elixir symbol extraction (modules, functions, macros, structs, protocols)
- [x] Add tree-sitter Elixir language parser integration
- [x] Add Elixir language detection for .ex and .exs file extensions
- [x] Add Elixir symbol extraction tests
- [x] Update language support table in README
- [x] Improve install script with package manager detection and LSP installation
- [x] Fix shell script portability (replace echo -e with printf)
- [x] Fix checksum verification in install script for macOS/Linux compatibility
2026-01-23 20:31:08 +00:00
71 changed files with 15783 additions and 2729 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Lint
on:
push:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
permissions:
contents: read
jobs:
golangci:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.9.0
args: --timeout=5m
+8 -1
View File
@@ -18,6 +18,13 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.24"
go-version: "1.25"
rolling-release-tag: "v1"
platforms: >-
[
{"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"},
{"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"},
{"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"},
{"os":"windows-latest","goos":"windows","goarch":"amd64","platform":"windows_amd64"}
]
secrets: inherit
+41
View File
@@ -0,0 +1,41 @@
name: Security Scan
on:
push:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
schedule:
# Run weekly on Monday at 9:00 UTC
- cron: '0 9 * * 1'
permissions:
contents: read
jobs:
govulncheck:
name: Run govulncheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
+1
View File
@@ -1,3 +1,4 @@
TODO.md
bin/mcp-filepuff
mcp-filepuff
dist/
+44
View File
@@ -0,0 +1,44 @@
version: "2"
run:
timeout: 5m
go: '1.25'
linters:
enable:
- errcheck # Check for unchecked errors
- govet # Vet examines Go source code
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis (includes gosimple)
- unused # Check for unused code
- gocyclo # Check cyclomatic complexity
- misspell # Check for misspelled words
formatters:
enable:
- gofmt # Check formatting
linters-settings:
gocyclo:
min-complexity: 15
errcheck:
check-blank: true
govet:
enable-all: true
staticcheck:
checks: ["all"]
issues:
exclude-rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
- gocyclo
- errcheck
- gosec
max-issues-per-linter: 0
max-same-issues: 0
output:
sort-results: true
+32 -11
View File
@@ -5,13 +5,19 @@ version: 2
project_name: mcp-filepuff
# Split mode by full target (goos+goarch). Each matrix runner only builds the
# artifact matching its own architecture, avoiding CGO cross-compile failures
# (ARM asm on x86 runners and vice versa).
partial:
by: target
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- id: mcp-filepuff
- id: mcp-filepuff-cgo
main: ./cmd/mcp-filepuff
binary: mcp-filepuff
env:
@@ -33,27 +39,20 @@ builds:
ignore:
- goos: darwin
goarch: amd64
- goos: linux
goarch: arm64
- goos: windows
goarch: arm64
archives:
- id: default
# Ship raw binaries — downloaders curl the asset and run it directly
# (Windows gets .exe appended automatically). No tarball indirection.
formats:
- tar.gz
- binary
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
files:
- LICENSE
- README.md
format_overrides:
- goos: windows
formats:
- zip
checksum:
name_template: 'checksums.txt'
@@ -90,6 +89,19 @@ release:
```
dockers:
- image_templates:
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
- "ghcr.io/lukaszraczylo/filepuff-mcp:latest"
ids:
- mcp-filepuff-cgo
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
goarch: amd64
goos: linux
dockerfile: Dockerfile
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
@@ -101,4 +113,13 @@ signs:
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- "sign"
- "--yes"
- "${artifact}"
+52
View File
@@ -0,0 +1,52 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
# Installation: pip install pre-commit && pre-commit install
#
# Note: These hooks are optional for contributors.
# Run manually with: pre-commit run --all-files
repos:
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
# Go formatting
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-vet
- id: go-mod-tidy
# golangci-lint
- repo: https://github.com/golangci/golangci-lint
rev: v1.56.2
hooks:
- id: golangci-lint
args: [--config=.golangci.yml]
# Markdown linting (optional)
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.39.0
hooks:
- id: markdownlint
args: ['--fix', '--disable', 'MD013', 'MD033', 'MD041', '--']
# Configuration for specific hooks
default_language_version:
golang: "1.22"
# Don't run on CI by default (handled by GitHub Actions)
ci:
skip: [golangci-lint, go-vet]
+3
View File
@@ -0,0 +1,3 @@
FROM gcr.io/distroless/base-debian12:nonroot
COPY mcp-filepuff /usr/local/bin/mcp-filepuff
ENTRYPOINT ["mcp-filepuff"]
+28 -3
View File
@@ -1,5 +1,4 @@
.PHONY: build test lint clean install run deps
.PHONY: build test lint clean install run deps docs
# Binary name
BINARY_NAME=mcp-filepuff
# Build directory
@@ -42,12 +41,29 @@ build-all:
# Run tests
test:
$(GOTEST) -v -race -coverprofile=coverage.out ./...
$(GOTEST) -v -coverprofile=coverage.out ./...
# Run tests with race detector on critical packages
test-race:
@echo "Running race detector tests on critical packages..."
$(GOTEST) -v -race -timeout=5m ./internal/edit/...
$(GOTEST) -v -race -timeout=5m ./internal/lsp/...
$(GOTEST) -v -race -timeout=5m ./internal/parser/...
$(GOTEST) -v -race -timeout=5m ./internal/server/...
@echo "Race detector tests completed successfully"
# Run tests with short flag
test-short:
$(GOTEST) -v -short ./...
# Run all tests including race detector
test-all: test test-race
# Run linters
lint:
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
golangci-lint run ./...
# Clean build artifacts
clean:
rm -rf $(BUILD_DIR)
@@ -72,9 +88,18 @@ help:
@echo " build - Build the binary"
@echo " build-all - Build for all platforms"
@echo " test - Run tests with coverage"
@echo " test-race - Run tests with race detector on critical packages"
@echo " test-all - Run all tests including race detector"
@echo " test-short - Run short tests"
@echo " lint - Run linters"
@echo " clean - Clean build artifacts"
@echo " install - Install binary to GOPATH/bin"
@echo " run - Build and run the server"
@echo " run-workspace - Run with specific workspace (WORKSPACE=/path)"
# Generate API documentation
docs:
@echo "Generating API documentation..."
$(GOCMD) run ./cmd/docgen
@echo "Documentation generated in docs/API.md"
+288 -27
View File
@@ -8,7 +8,7 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
- **AST-Aware File Reading**: Read files with symbol extraction using Tree-sitter
- **Code Pattern Matching**: Query code using patterns with capture placeholders
- **LSP Integration**: Go-to-definition, find references, and symbol info via language servers
- **Safe Editing**: AST-aware file editing with syntax validation and preview
- **Safe Editing**: AST-aware file editing with syntax validation (edit_apply)
- **Multi-Language Support**: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, React
- **Token Efficient**: Optimized for minimal token usage with symbols-only mode and output limiting
@@ -29,30 +29,50 @@ This script will:
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
- Make the binary executable
### Docker
```bash
docker pull ghcr.io/lukaszraczylo/filepuff-mcp:latest
```
The MCP server communicates over stdio. Mount your workspace and run with `-i`:
```bash
docker run -i --rm -v /path/to/workspace:/workspace ghcr.io/lukaszraczylo/filepuff-mcp:latest -workspace /workspace
```
Claude Code configuration (`.claude/settings.json`):
```json
{
"mcpServers": {
"filepuff": {
"command": "docker",
"args": ["run", "-i", "--rm", "-v", ".:/workspace", "ghcr.io/lukaszraczylo/filepuff-mcp:latest", "-workspace", "/workspace"]
}
}
}
```
### Manual Installation
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
```bash
# macOS (ARM64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# macOS (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_amd64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# macOS (Apple Silicon)
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
# Linux (ARM64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
# Linux (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
curl -fsSL -o mcp-filepuff https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64
chmod +x mcp-filepuff && mv mcp-filepuff ~/.local/bin/
# Windows (PowerShell)
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.zip" -OutFile mcp-filepuff.zip
Expand-Archive mcp-filepuff.zip -DestinationPath $env:USERPROFILE\.local\bin
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.exe" -OutFile mcp-filepuff.exe
Move-Item mcp-filepuff.exe $env:USERPROFILE\.local\bin\
```
Replace `<version>` with the actual version (e.g., `v1.0.0`).
@@ -104,6 +124,39 @@ After downloading or building the binary, configure it in Claude Code:
See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for more details.
### Recommended Claude Code Configuration
#### Selective Tool Deferral
For optimal performance, keep the most frequently used tools loaded at startup and defer the rest. This follows [Anthropic's recommended practice](https://www.anthropic.com/engineering/advanced-tool-use) of loading 3-5 high-use tools immediately:
```json
{
"mcpServers": {
"filepuff": {
"command": "mcp-filepuff",
"args": ["-workspace", "."],
"alwaysAllow": ["file_read", "file_search", "edit_apply"]
}
}
}
```
This keeps `file_read`, `file_search`, and `edit_apply` immediately available while deferring less frequently used tools (`ping`, `ast_query`, `symbol_at`, `find_definition`, `find_references`).
#### System Prompt Guidance
Add the following to your `CLAUDE.md` to help Claude understand the available tool categories:
```markdown
You have access to filepuff MCP tools providing:
- File reading with AST symbol summaries (file_read)
- Fast regex search powered by ripgrep (file_search)
- Structural code pattern matching across 9+ languages (ast_query)
- LSP-powered go-to-definition, find-references, and symbol info (find_definition, find_references, symbol_at)
- AST-aware file editing with syntax validation (edit_apply)
```
## Usage
### Running the Server (Standalone)
@@ -184,7 +237,7 @@ When performing file operations, prefer filepuff MCP tools over built-in equival
| Read files | `mcp__filepuff__file_read` | Read |
| Search content | `mcp__filepuff__file_search` | Grep |
| AST pattern search | `mcp__filepuff__ast_query` | Grep/Glob |
| Edit files | `mcp__filepuff__edit_preview` + `mcp__filepuff__edit_apply` | Edit |
| Edit files | `mcp__filepuff__edit_apply` | Edit |
| Find definitions | `mcp__filepuff__find_definition` | Grep |
| Find references | `mcp__filepuff__find_references` | Grep |
| Symbol info | `mcp__filepuff__symbol_at` | - |
@@ -342,8 +395,8 @@ Find all references to the symbol at a specific position.
---
### `edit_preview`
Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).
### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
**Parameters**:
- `file` (required): Path to the file to edit
@@ -363,13 +416,6 @@ Preview an edit without applying it. Uses AST-aware editing for code files (Go,
- `selector_text`: Exact text to match (must be unique or use selector_index)
- `selector_pattern`: Regex pattern to match
---
### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
**Parameters**: Same as `edit_preview`
**Example (AST mode - Go file)**:
```json
{
@@ -425,6 +471,7 @@ Apply an edit to a file. Uses AST-aware editing for code files with syntax valid
| HTML | .html, .htm | Yes | Yes | - | Yes |
| Vue | .vue | Yes | Yes* | - | Yes |
| React | .jsx, .tsx | Yes | Yes | typescript-language-server | Yes |
| Elixir | .ex, .exs | Yes | Yes | elixir-ls | Yes |
\* Vue uses HTML parser for template sections
@@ -479,13 +526,14 @@ make clean
## Architecture
### High-Level Overview
```
┌─────────────────────────────────────────────────────────┐
│ MCP Server │
├─────────────────────────────────────────────────────────┤
│ Tools: file_search, file_read, ast_query, symbol_at, │
│ find_definition, find_references, │
│ edit_preview, edit_apply, ping │
│ find_definition, find_references, edit_apply, ping │
├─────────────────────────────────────────────────────────┤
│ Core Engines │
├───────────┬─────────────┬────────────┬─────────────────┤
@@ -494,6 +542,220 @@ make clean
└───────────┴─────────────┴────────────┴─────────────────┘
```
### Detailed Sequence Diagrams
#### LSP Integration Flow
The following diagram shows how LSP requests (hover, definition, references) flow through the system:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant LSPMgr as LSP Manager
participant LSPSrv as LSP Server (gopls/etc)
participant FS as File System
Client->>Server: symbol_at(file, line, col)
activate Server
Server->>LSPMgr: GetServer(language)
activate LSPMgr
alt Server Not Running
LSPMgr->>LSPSrv: Start Process
LSPMgr->>LSPSrv: initialize request
LSPSrv-->>LSPMgr: capabilities
LSPMgr->>LSPSrv: initialized notification
end
LSPMgr-->>Server: ManagedServer
deactivate LSPMgr
Server->>LSPMgr: ensureDocumentOpen(file)
activate LSPMgr
alt Document Not Open
LSPMgr->>FS: ReadFile(path)
FS-->>LSPMgr: content
LSPMgr->>LSPSrv: textDocument/didOpen
end
LSPMgr-->>Server: ready
deactivate LSPMgr
Server->>LSPSrv: textDocument/hover
activate LSPSrv
LSPSrv-->>Server: HoverResult
deactivate LSPSrv
Server-->>Client: Symbol information
deactivate Server
```
#### Edit Operation Flow
The edit engine uses atomic writes and validation to ensure safe file modifications:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant Edit as Edit Engine
participant Parser as Parser Registry
participant FS as File System
Client->>Server: edit_apply(file, operation, selector, content)
activate Server
Server->>Edit: Apply(ctx, edit)
activate Edit
Edit->>Edit: lockFile(path)
Note over Edit: Per-file mutex prevents<br/>concurrent edits
Edit->>FS: ReadFile(path)
FS-->>Edit: original content
alt AST-Aware Mode (code files)
Edit->>Parser: Parse(ctx, path, content)
activate Parser
Parser-->>Edit: ParseResult with AST
deactivate Parser
Edit->>Edit: resolveSelector(selector, tree)
Note over Edit: Find target node by<br/>kind, name, line, index
Edit->>Edit: applyEdit(operation, node, content)
Note over Edit: Apply replace/insert/delete<br/>with indentation preservation
Edit->>Parser: Parse(ctx, path, newContent)
activate Parser
Parser-->>Edit: Validate syntax
deactivate Parser
alt Syntax Error
Edit-->>Server: ValidationError
Server-->>Client: Error: invalid syntax
end
else Text Mode (non-code files)
Edit->>Edit: resolveTextSelector(selector)
Note over Edit: Find by text, pattern,<br/>or line range
Edit->>Edit: applyTextEditOperation
end
Edit->>Edit: generateDiff(original, new)
Edit->>FS: Stat(path) - get permissions
Edit->>FS: WriteFile(path, content, perm)
Note over Edit,FS: Atomic write preserves<br/>original permissions
Edit->>Edit: unlockFile(path)
Edit-->>Server: EditResult{Success, Diff}
deactivate Edit
Server-->>Client: Success + Diff
deactivate Server
```
#### Parse and Cache Flow
The parser uses content-based caching for efficient AST reuse:
```mermaid
sequenceDiagram
participant Client as MCP Client
participant Server as MCP Server
participant Parser as Parser Registry
participant Cache as LRU Cache
participant TS as Tree-sitter
Client->>Server: file_read(path, include_ast=true)
activate Server
Server->>Parser: Parse(ctx, path, content)
activate Parser
Parser->>Parser: contentHash(content)
Note over Parser: xxHash64 for fast<br/>content fingerprinting
Parser->>Cache: Get(hash)
activate Cache
alt Cache Hit
Cache-->>Parser: CachedTree
Parser->>Parser: cacheHits++
Note over Parser: ~100x faster than parsing
else Cache Miss
Cache-->>Parser: nil
deactivate Cache
Parser->>Parser: cacheMisses++
Parser->>Parser: GetParser(language)
Note over Parser: One parser per language,<br/>reused across requests
Parser->>TS: ParseCtx(ctx, content)
activate TS
Note over TS: Tree-sitter parsing<br/>with timeout support
TS-->>Parser: *sitter.Tree
deactivate TS
Parser->>Cache: Add(hash, tree)
activate Cache
Note over Cache: LRU eviction when<br/>capacity reached (100 entries)
Cache-->>Parser: stored
deactivate Cache
end
Parser->>Parser: extractErrors(tree)
Parser->>Parser: ExtractSymbols(tree)
Parser-->>Server: ParseResult{Tree, Language, Errors, Symbols}
deactivate Parser
Server->>Server: generateASTSummary()
Server-->>Client: File content + Symbol summary
deactivate Server
```
#### Request Flow Summary
```mermaid
flowchart TB
subgraph "MCP Protocol Layer"
A[MCP Client] --> B[MCP Server]
end
subgraph "Tool Handlers"
B --> C{Tool Type}
C -->|Search| D[file_search]
C -->|Read| E[file_read]
C -->|Query| F[ast_query]
C -->|LSP| G[symbol_at<br/>find_definition<br/>find_references]
C -->|Edit| H[edit_apply]
end
subgraph "Core Engines"
D --> I[Search Engine<br/>ripgrep]
E --> J[Parser Registry]
F --> J
F --> K[Query Matcher]
G --> L[LSP Manager]
H --> M[Edit Engine]
M --> J
end
subgraph "External Systems"
I --> N[(File System)]
J --> O[Tree-sitter]
J --> P[(Parse Cache)]
L --> Q[gopls<br/>typescript-language-server<br/>pylsp<br/>clangd]
M --> N
end
```
## Troubleshooting
### Common Issues
@@ -545,7 +807,6 @@ The edit engine validates syntax before and after edits.
**Solution**:
- Ensure `new_content` is syntactically valid for the target language
- Use `edit_preview` first to see the proposed changes
- Check that the selector matches exactly one node
#### Timeout Errors
+352
View File
@@ -0,0 +1,352 @@
// Package main implements an API documentation generator for MCP tools.
package main
import (
"fmt"
"os"
"strings"
"time"
)
// ToolParameter represents a parameter for an MCP tool.
type ToolParameter struct {
Name string
Type string
Description string
Required bool
}
// Tool represents an MCP tool with its documentation.
type Tool struct {
Name string
Description string
Parameters []ToolParameter
Examples []string
Notes []string
ReadOnly bool
Category string
}
// AllTools returns the complete list of MCP tools with their documentation.
func AllTools() []Tool {
return []Tool{
{
Name: "ping",
Description: "Health check - returns pong to verify the server is running",
Category: "System",
ReadOnly: true,
Parameters: []ToolParameter{},
Examples: []string{
`{"tool": "ping"}`,
},
Notes: []string{
"Returns: \"pong\"",
"Use to verify server connectivity",
},
},
{
Name: "file_search",
Description: "Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines.",
Category: "Search",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "pattern", Type: "string", Required: true, Description: "The search pattern (regex by default)"},
{Name: "paths", Type: "array[string]", Required: false, Description: "Paths to search in (defaults to workspace root)"},
{Name: "file_types", Type: "array[string]", Required: false, Description: "File types to search (e.g., ['go', 'ts', 'py'])"},
{Name: "ignore_case", Type: "boolean", Required: false, Description: "Case insensitive search"},
{Name: "regex", Type: "boolean", Required: false, Description: "Treat pattern as regex (default: true)"},
{Name: "context_lines", Type: "number", Required: false, Description: "Number of context lines around matches (default: 2)"},
{Name: "max_results", Type: "number", Required: false, Description: "Maximum number of results to return"},
},
Examples: []string{
`{"pattern": "func.*Error", "file_types": ["go"]}`,
`{"pattern": "TODO", "ignore_case": true, "context_lines": 3}`,
},
Notes: []string{
"Requires ripgrep (rg) to be installed",
"Respects .gitignore by default",
},
},
{
Name: "file_read",
Description: "Read a file's contents with optional line range and AST symbol summary",
Category: "File Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "path", Type: "string", Required: true, Description: "Path to the file to read"},
{Name: "line_start", Type: "number", Required: false, Description: "Starting line number (1-indexed)"},
{Name: "line_end", Type: "number", Required: false, Description: "Ending line number (inclusive)"},
{Name: "include_ast", Type: "boolean", Required: false, Description: "Include AST symbol summary (functions, classes, types, etc.)"},
{Name: "symbols_only", Type: "boolean", Required: false, Description: "Return only symbol summary without file content (token-efficient mode). Requires include_ast=true."},
{Name: "max_lines", Type: "number", Required: false, Description: "Maximum number of lines to return (for token efficiency). Applied after line_start/line_end."},
},
Examples: []string{
`{"path": "server.go", "include_ast": true}`,
`{"path": "server.go", "include_ast": true, "symbols_only": true}`,
`{"path": "server.go", "line_start": 10, "line_end": 50}`,
`{"path": "large_file.go", "max_lines": 100}`,
},
Notes: []string{
"symbols_only mode reduces token usage by ~90-98%",
"max_lines truncates output with notification",
"AST symbols show line numbers for quick navigation",
},
},
{
Name: "ast_query",
Description: "Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types.",
Category: "AST Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "pattern", Type: "string", Required: true, Description: "Code pattern with placeholders: $NAME (single), $$$ARGS (multiple), $_ (wildcard). Examples: 'func $NAME($$$ARGS) error', 'class $NAME { $$$BODY }'"},
{Name: "language", Type: "string", Required: true, Description: "Target language: go, typescript, javascript, python, c, cpp"},
{Name: "paths", Type: "array[string]", Required: false, Description: "Paths to search in (defaults to workspace root)"},
{Name: "name_matches", Type: "string", Required: false, Description: "Regex pattern to filter by name"},
{Name: "name_exact", Type: "string", Required: false, Description: "Exact name to match"},
{Name: "kind_in", Type: "array[string]", Required: false, Description: "Node types to match (e.g., function_declaration, class_declaration)"},
{Name: "max_results", Type: "number", Required: false, Description: "Maximum number of results to return (default: 100)"},
},
Examples: []string{
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}`,
`{"pattern": "class $NAME: $$$BODY", "language": "python"}`,
`{"pattern": "function $NAME($PROPS) { $$$BODY }", "language": "javascript", "name_matches": "^[A-Z]"}`,
},
Notes: []string{
"$NAME captures identifiers",
"$$$ARGS captures multiple items (parameters, body, etc.)",
"$_ is a wildcard that matches but doesn't capture",
"Powered by Tree-sitter for accurate AST parsing",
},
},
{
Name: "symbol_at",
Description: "Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
},
Examples: []string{
`{"file": "server.go", "line": 25, "column": 10}`,
},
Notes: []string{
"Requires LSP server for full type information",
"Falls back to AST-based info if LSP unavailable",
},
},
{
Name: "find_definition",
Description: "Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
},
Examples: []string{
`{"file": "server.go", "line": 42, "column": 15}`,
},
Notes: []string{
"Requires language server for the file type",
"Returns file path, line, and column of definition",
"Shows code preview at definition location",
},
},
{
Name: "find_references",
Description: "Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc.",
Category: "LSP Operations",
ReadOnly: true,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file"},
{Name: "line", Type: "number", Required: true, Description: "Line number (1-indexed)"},
{Name: "column", Type: "number", Required: true, Description: "Column number (1-indexed)"},
{Name: "include_declaration", Type: "boolean", Required: false, Description: "Include the declaration in results (default: true)"},
},
Examples: []string{
`{"file": "server.go", "line": 42, "column": 15}`,
`{"file": "server.go", "line": 42, "column": 15, "include_declaration": false}`,
},
Notes: []string{
"Requires language server for the file type",
"Results grouped by file",
},
},
{
Name: "edit_apply",
Description: "Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).",
Category: "Edit Operations",
ReadOnly: false,
Parameters: []ToolParameter{
{Name: "file", Type: "string", Required: true, Description: "Path to the file to edit"},
{Name: "operation", Type: "string", Required: true, Description: "Edit operation: replace, insert_before, insert_after, delete"},
{Name: "new_content", Type: "string", Required: false, Description: "New content (required for replace/insert operations)"},
{Name: "selector_kind", Type: "string", Required: false, Description: "AST node type to match (e.g., function_declaration, class_declaration). For code files only."},
{Name: "selector_name", Type: "string", Required: false, Description: "Name of the symbol to match. For code files only."},
{Name: "selector_line", Type: "number", Required: false, Description: "Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range."},
{Name: "selector_index", Type: "number", Required: false, Description: "Index of the match to use if multiple matches found (default: 0)"},
{Name: "selector_line_end", Type: "number", Required: false, Description: "End line number for range selection (text mode). Used with selector_line."},
{Name: "selector_text", Type: "string", Required: false, Description: "Exact text to match (text mode). Must be unique or use selector_index."},
{Name: "selector_pattern", Type: "string", Required: false, Description: "Regex pattern to match (text mode). Must be unique or use selector_index."},
},
Examples: []string{
`{"file": "server.go", "operation": "replace", "selector_kind": "function_declaration", "selector_name": "Hello", "new_content": "func Hello() {\\n\\tprintln(\\"Updated\\")\\n}"}`,
`{"file": "config.yaml", "operation": "replace", "selector_line": 5, "selector_line_end": 10, "new_content": "database:\\n host: production.db.example.com\\n port: 5432"}`,
},
Notes: []string{
"For code files: validates syntax before and after edit",
"Preserves file permissions",
"Uses atomic writes for safety",
"File locking prevents concurrent edits",
},
},
}
}
// GenerateMarkdown generates the complete API documentation in Markdown format.
func GenerateMarkdown() string {
var sb strings.Builder
sb.WriteString("# MCP Filepuff API Reference\n\n")
sb.WriteString(fmt.Sprintf("> Auto-generated on %s\n\n", time.Now().Format("2006-01-02")))
sb.WriteString("This document provides detailed API documentation for all MCP tools available in filepuff.\n\n")
// Table of Contents
sb.WriteString("## Table of Contents\n\n")
tools := AllTools()
categories := make(map[string][]Tool)
categoryOrder := []string{}
for _, tool := range tools {
if _, ok := categories[tool.Category]; !ok {
categoryOrder = append(categoryOrder, tool.Category)
}
categories[tool.Category] = append(categories[tool.Category], tool)
}
for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n", cat))
for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("- [`%s`](#%s)\n", tool.Name, tool.Name))
}
sb.WriteString("\n")
}
// Tool Documentation
sb.WriteString("---\n\n")
sb.WriteString("## Tool Reference\n\n")
for _, cat := range categoryOrder {
sb.WriteString(fmt.Sprintf("### %s\n\n", cat))
for _, tool := range categories[cat] {
sb.WriteString(fmt.Sprintf("#### `%s`\n\n", tool.Name))
sb.WriteString(fmt.Sprintf("%s\n\n", tool.Description))
// Read-only indicator
if tool.ReadOnly {
sb.WriteString("🔒 **Read-only**: This tool does not modify files.\n\n")
} else {
sb.WriteString("⚠️ **Modifies files**: This tool writes to the filesystem.\n\n")
}
// Parameters
if len(tool.Parameters) > 0 {
sb.WriteString("**Parameters:**\n\n")
sb.WriteString("| Name | Type | Required | Description |\n")
sb.WriteString("|------|------|----------|-------------|\n")
for _, param := range tool.Parameters {
required := "No"
if param.Required {
required = "**Yes**"
}
sb.WriteString(fmt.Sprintf("| `%s` | `%s` | %s | %s |\n",
param.Name, param.Type, required, param.Description))
}
sb.WriteString("\n")
} else {
sb.WriteString("**Parameters:** None\n\n")
}
// Examples
if len(tool.Examples) > 0 {
sb.WriteString("**Examples:**\n\n")
for _, example := range tool.Examples {
sb.WriteString("```json\n")
sb.WriteString(example)
sb.WriteString("\n```\n\n")
}
}
// Notes
if len(tool.Notes) > 0 {
sb.WriteString("**Notes:**\n\n")
for _, note := range tool.Notes {
sb.WriteString(fmt.Sprintf("- %s\n", note))
}
sb.WriteString("\n")
}
sb.WriteString("---\n\n")
}
}
// Additional sections
sb.WriteString("## Supported Languages\n\n")
sb.WriteString("| Language | Extensions | Search | AST | LSP | Edit |\n")
sb.WriteString("|----------|-----------|--------|-----|-----|------|\n")
sb.WriteString("| Go | .go | ✅ | ✅ | gopls | ✅ |\n")
sb.WriteString("| TypeScript | .ts, .tsx | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| JavaScript | .js, .jsx, .mjs, .cjs | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| Python | .py, .pyw | ✅ | ✅ | pylsp | ✅ |\n")
sb.WriteString("| C | .c, .h | ✅ | ✅ | clangd | ✅ |\n")
sb.WriteString("| C++ | .cpp, .cc, .cxx, .hpp, .hxx | ✅ | ✅ | clangd | ✅ |\n")
sb.WriteString("| HTML | .html, .htm | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("| Vue | .vue | ✅ | ✅* | - | ✅ |\n")
sb.WriteString("| React | .jsx, .tsx | ✅ | ✅ | typescript-language-server | ✅ |\n")
sb.WriteString("| Elixir | .ex, .exs | ✅ | ✅ | elixir-ls | ✅ |\n")
sb.WriteString("| JSON | .json | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("| YAML | .yaml, .yml | ✅ | ✅ | - | ✅ |\n")
sb.WriteString("\n\\* Vue uses HTML parser for template sections\n\n")
sb.WriteString("## Error Handling\n\n")
sb.WriteString("All tools return structured errors with:\n")
sb.WriteString("- **Error code**: Numeric identifier for the error type\n")
sb.WriteString("- **Message**: Human-readable error description\n")
sb.WriteString("- **Context**: Additional information about the error\n")
sb.WriteString("- **Remediation**: Suggested fix for the error\n\n")
sb.WriteString("See [ERROR_CODES.md](ERROR_CODES.md) for a complete error reference.\n\n")
sb.WriteString("## See Also\n\n")
sb.WriteString("- [README.md](../README.md) - Project overview and installation\n")
sb.WriteString("- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference\n")
sb.WriteString("- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide\n")
return sb.String()
}
func main() {
output := GenerateMarkdown()
// Default output path
outputPath := "docs/API.md"
if len(os.Args) > 1 {
outputPath = os.Args[1]
}
// Ensure docs directory exists
if err := os.MkdirAll("docs", 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating docs directory: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Printf("API documentation generated: %s\n", outputPath)
}
+13 -1
View File
@@ -67,9 +67,11 @@ func setupLogger(level string, logFile string) *slog.Logger {
}
var handler slog.Handler
var logFileErr error
if logFile != "" {
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
logFileErr = err
// Fallback to stderr
handler = slog.NewJSONHandler(os.Stderr, opts)
} else {
@@ -80,5 +82,15 @@ func setupLogger(level string, logFile string) *slog.Logger {
handler = slog.NewJSONHandler(os.Stderr, opts)
}
return slog.New(handler)
logger := slog.New(handler)
// Warn if log file couldn't be opened
if logFileErr != nil {
logger.Warn("failed to open log file, using stderr",
"file", logFile,
"error", logFileErr,
)
}
return logger
}
+1688 -1342
View File
File diff suppressed because it is too large Load Diff
+345
View File
@@ -0,0 +1,345 @@
# MCP Filepuff API Reference
> Auto-generated on 2026-03-12
This document provides detailed API documentation for all MCP tools available in filepuff.
## Table of Contents
### System
- [`ping`](#ping)
### Search
- [`file_search`](#file_search)
### File Operations
- [`file_read`](#file_read)
### AST Operations
- [`ast_query`](#ast_query)
### LSP Operations
- [`symbol_at`](#symbol_at)
- [`find_definition`](#find_definition)
- [`find_references`](#find_references)
### Edit Operations
- [`edit_apply`](#edit_apply)
---
## Tool Reference
### System
#### `ping`
Health check - returns pong to verify the server is running
🔒 **Read-only**: This tool does not modify files.
**Parameters:** None
**Examples:**
```json
{"tool": "ping"}
```
**Notes:**
- Returns: "pong"
- Use to verify server connectivity
---
### Search
#### `file_search`
Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `pattern` | `string` | **Yes** | The search pattern (regex by default) |
| `paths` | `array[string]` | No | Paths to search in (defaults to workspace root) |
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
| `ignore_case` | `boolean` | No | Case insensitive search |
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
| `context_lines` | `number` | No | Number of context lines around matches (default: 2) |
| `max_results` | `number` | No | Maximum number of results to return |
**Examples:**
```json
{"pattern": "func.*Error", "file_types": ["go"]}
```
```json
{"pattern": "TODO", "ignore_case": true, "context_lines": 3}
```
**Notes:**
- Requires ripgrep (rg) to be installed
- Respects .gitignore by default
---
### File Operations
#### `file_read`
Read a file's contents with optional line range and AST symbol summary
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `path` | `string` | **Yes** | Path to the file to read |
| `line_start` | `number` | No | Starting line number (1-indexed) |
| `line_end` | `number` | No | Ending line number (inclusive) |
| `include_ast` | `boolean` | No | Include AST symbol summary (functions, classes, types, etc.) |
| `symbols_only` | `boolean` | No | Return only symbol summary without file content (token-efficient mode). Requires include_ast=true. |
| `max_lines` | `number` | No | Maximum number of lines to return (for token efficiency). Applied after line_start/line_end. |
**Examples:**
```json
{"path": "server.go", "include_ast": true}
```
```json
{"path": "server.go", "include_ast": true, "symbols_only": true}
```
```json
{"path": "server.go", "line_start": 10, "line_end": 50}
```
```json
{"path": "large_file.go", "max_lines": 100}
```
**Notes:**
- symbols_only mode reduces token usage by ~90-98%
- max_lines truncates output with notification
- AST symbols show line numbers for quick navigation
---
### AST Operations
#### `ast_query`
Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `pattern` | `string` | **Yes** | Code pattern with placeholders: $NAME (single), $$$ARGS (multiple), $_ (wildcard). Examples: 'func $NAME($$$ARGS) error', 'class $NAME { $$$BODY }' |
| `language` | `string` | **Yes** | Target language: go, typescript, javascript, python, c, cpp |
| `paths` | `array[string]` | No | Paths to search in (defaults to workspace root) |
| `name_matches` | `string` | No | Regex pattern to filter by name |
| `name_exact` | `string` | No | Exact name to match |
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
| `max_results` | `number` | No | Maximum number of results to return (default: 100) |
**Examples:**
```json
{"pattern": "func $NAME($$$ARGS) error", "language": "go"}
```
```json
{"pattern": "class $NAME: $$$BODY", "language": "python"}
```
```json
{"pattern": "function $NAME($PROPS) { $$$BODY }", "language": "javascript", "name_matches": "^[A-Z]"}
```
**Notes:**
- $NAME captures identifiers
- $$$ARGS captures multiple items (parameters, body, etc.)
- $_ is a wildcard that matches but doesn't capture
- Powered by Tree-sitter for accurate AST parsing
---
### LSP Operations
#### `symbol_at`
Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
**Examples:**
```json
{"file": "server.go", "line": 25, "column": 10}
```
**Notes:**
- Requires LSP server for full type information
- Falls back to AST-based info if LSP unavailable
---
#### `find_definition`
Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
**Examples:**
```json
{"file": "server.go", "line": 42, "column": 15}
```
**Notes:**
- Requires language server for the file type
- Returns file path, line, and column of definition
- Shows code preview at definition location
---
#### `find_references`
Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc.
🔒 **Read-only**: This tool does not modify files.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file |
| `line` | `number` | **Yes** | Line number (1-indexed) |
| `column` | `number` | **Yes** | Column number (1-indexed) |
| `include_declaration` | `boolean` | No | Include the declaration in results (default: true) |
**Examples:**
```json
{"file": "server.go", "line": 42, "column": 15}
```
```json
{"file": "server.go", "line": 42, "column": 15, "include_declaration": false}
```
**Notes:**
- Requires language server for the file type
- Results grouped by file
---
### Edit Operations
#### `edit_apply`
Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).
⚠️ **Modifies files**: This tool writes to the filesystem.
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file` | `string` | **Yes** | Path to the file to edit |
| `operation` | `string` | **Yes** | Edit operation: replace, insert_before, insert_after, delete |
| `new_content` | `string` | No | New content (required for replace/insert operations) |
| `selector_kind` | `string` | No | AST node type to match (e.g., function_declaration, class_declaration). For code files only. |
| `selector_name` | `string` | No | Name of the symbol to match. For code files only. |
| `selector_line` | `number` | No | Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range. |
| `selector_index` | `number` | No | Index of the match to use if multiple matches found (default: 0) |
| `selector_line_end` | `number` | No | End line number for range selection (text mode). Used with selector_line. |
| `selector_text` | `string` | No | Exact text to match (text mode). Must be unique or use selector_index. |
| `selector_pattern` | `string` | No | Regex pattern to match (text mode). Must be unique or use selector_index. |
**Examples:**
```json
{"file": "server.go", "operation": "replace", "selector_kind": "function_declaration", "selector_name": "Hello", "new_content": "func Hello() {\\n\\tprintln(\\"Updated\\")\\n}"}
```
```json
{"file": "config.yaml", "operation": "replace", "selector_line": 5, "selector_line_end": 10, "new_content": "database:\\n host: production.db.example.com\\n port: 5432"}
```
**Notes:**
- For code files: validates syntax before and after edit
- Preserves file permissions
- Uses atomic writes for safety
- File locking prevents concurrent edits
---
## Supported Languages
| Language | Extensions | Search | AST | LSP | Edit |
|----------|-----------|--------|-----|-----|------|
| Go | .go | ✅ | ✅ | gopls | ✅ |
| TypeScript | .ts, .tsx | ✅ | ✅ | typescript-language-server | ✅ |
| JavaScript | .js, .jsx, .mjs, .cjs | ✅ | ✅ | typescript-language-server | ✅ |
| Python | .py, .pyw | ✅ | ✅ | pylsp | ✅ |
| C | .c, .h | ✅ | ✅ | clangd | ✅ |
| C++ | .cpp, .cc, .cxx, .hpp, .hxx | ✅ | ✅ | clangd | ✅ |
| HTML | .html, .htm | ✅ | ✅ | - | ✅ |
| Vue | .vue | ✅ | ✅* | - | ✅ |
| React | .jsx, .tsx | ✅ | ✅ | typescript-language-server | ✅ |
| Elixir | .ex, .exs | ✅ | ✅ | elixir-ls | ✅ |
| JSON | .json | ✅ | ✅ | - | ✅ |
| YAML | .yaml, .yml | ✅ | ✅ | - | ✅ |
\* Vue uses HTML parser for template sections
## Error Handling
All tools return structured errors with:
- **Error code**: Numeric identifier for the error type
- **Message**: Human-readable error description
- **Context**: Additional information about the error
- **Remediation**: Suggested fix for the error
See [ERROR_CODES.md](ERROR_CODES.md) for a complete error reference.
## See Also
- [README.md](../README.md) - Project overview and installation
- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference
- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide
+653
View File
@@ -0,0 +1,653 @@
# MCP Filepuff Error Codes Reference
This document provides a comprehensive reference for all error codes that can be returned by mcp-filepuff, along with descriptions and remediation steps.
## Error Code Structure
All errors in mcp-filepuff use structured error handling with the following components:
- **Error Code**: A numeric identifier (1000-1999) for programmatic handling
- **Message**: Human-readable description of the error
- **Context**: Additional key-value pairs providing error context
- **Remediation**: Suggested steps to resolve the error
- **Cause**: Underlying error (if wrapping another error)
## Error Categories
| Range | Category | Description |
|-------|----------|-------------|
| 1000-1099 | Search Errors | File search and ripgrep operations |
| 1100-1199 | Parser Errors | AST parsing and tree-sitter operations |
| 1200-1299 | LSP Errors | Language server protocol operations |
| 1300-1399 | Edit Errors | File editing operations |
| 1400-1499 | Query Errors | AST pattern matching operations |
| 1500-1599 | Config Errors | Configuration and validation |
| 1900-1999 | Internal Errors | Internal server errors |
---
## Search Errors (1000-1099)
### 1001 - ErrRipgrepNotFound
**Description**: The ripgrep (`rg`) binary is not found in the system PATH.
**Common Causes**:
- ripgrep is not installed
- ripgrep is not in PATH
- PATH environment variable not properly set
**Remediation**:
```bash
# macOS
brew install ripgrep
# Ubuntu/Debian
sudo apt install ripgrep
# Windows (Chocolatey)
choco install ripgrep
# Windows (Scoop)
scoop install ripgrep
```
---
### 1002 - ErrRipgrepTimeout
**Description**: Search operation exceeded the configured timeout limit.
**Context Fields**:
- `pattern`: The search pattern that timed out
- `duration`: How long the search ran before timing out
**Common Causes**:
- Search pattern too broad (e.g., `.` matching everything)
- Searching a very large directory tree
- Complex regex pattern causing backtracking
**Remediation**:
1. Use more specific search patterns
2. Narrow the search scope with `paths` parameter
3. Increase timeout via `MCP_SEARCH_TIMEOUT` environment variable:
```bash
export MCP_SEARCH_TIMEOUT="2m"
```
---
### 1003 - ErrInvalidPattern
**Description**: The search pattern is invalid or malformed.
**Context Fields**:
- `pattern`: The invalid pattern
- `error`: Specific parsing error
**Common Causes**:
- Invalid regex syntax
- Unclosed brackets or parentheses
- Invalid escape sequences
**Remediation**:
1. Validate regex syntax at [regex101.com](https://regex101.com)
2. Escape special characters properly
3. Use `regex: false` for literal string searches
---
### 1004 - ErrSearchFailed
**Description**: General search operation failure.
**Context Fields**:
- `error`: Underlying error message
**Common Causes**:
- Permission denied on files/directories
- I/O errors
- ripgrep process crashed
**Remediation**:
1. Check file and directory permissions
2. Verify workspace path is accessible
3. Check system logs for I/O errors
---
### 1005 - ErrNoResults
**Description**: Search completed but found no matches.
**Note**: This is an informational code, not necessarily an error.
---
## Parser Errors (1100-1199)
### 1101 - ErrParserNotFound
**Description**: No Tree-sitter parser is available for the requested language.
**Context Fields**:
- `language`: The unsupported language
**Supported Languages**:
- Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, JSON, YAML
**Remediation**:
1. Use a supported language
2. Check file extension is correct
---
### 1102 - ErrParseFailed
**Description**: Tree-sitter failed to parse the file.
**Context Fields**:
- `file`: Path to the file
- `language`: Detected language
- `error`: Specific parsing error
**Common Causes**:
- File has syntax errors
- File encoding is not UTF-8
- Binary file detected
**Remediation**:
1. Fix syntax errors in the source file
2. Ensure file is valid UTF-8
3. Check file is not a binary
---
### 1103 - ErrInvalidLanguage
**Description**: The specified or detected language is not supported.
**Context Fields**:
- `language`: The unsupported language
- `file`: File path (if detected from extension)
**Remediation**:
- Use a file with a supported extension
- Supported: `.go`, `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.c`, `.h`, `.cpp`, `.hpp`, `.html`, `.vue`, `.ex`, `.exs`, `.json`, `.yaml`, `.yml`
---
### 1104 - ErrFileTooBig
**Description**: File exceeds the maximum size limit for parsing.
**Context Fields**:
- `file`: Path to the file
- `size_bytes`: Actual file size
- `limit_bytes`: Maximum allowed size
**Default Limit**: 10 MB
**Remediation**:
1. Process smaller files
2. Increase limit via configuration:
```json
{
"max_parse_size": 20971520
}
```
---
### 1105 - ErrInvalidSyntax
**Description**: File contains syntax errors that prevent proper parsing.
**Context Fields**:
- `file`: Path to the file
- `errors`: List of syntax errors with locations
**Remediation**:
1. Fix syntax errors in the source file
2. Run language-specific linters/compilers to identify issues
---
## LSP Errors (1200-1299)
### 1201 - ErrLSPServerNotFound
**Description**: The LSP server for the requested language is not installed or not in PATH.
**Context Fields**:
- `language`: The language needing LSP
- `server`: The expected server binary name
**LSP Servers by Language**:
| Language | Server | Installation |
|----------|--------|--------------|
| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` |
| TypeScript/JavaScript | `typescript-language-server` | `npm install -g typescript-language-server typescript` |
| Python | `pylsp` | `pip install python-lsp-server` |
| C/C++ | `clangd` | Install via system package manager |
**Remediation**:
Install the appropriate language server for your language.
---
### 1202 - ErrLSPInitFailed
**Description**: LSP server failed to initialize.
**Context Fields**:
- `language`: The language
- `command`: The server command
- `error`: Specific initialization error
**Common Causes**:
- Workspace configuration issues
- Missing dependencies for the project
- LSP server crashed during startup
**Remediation**:
1. Check LSP server logs
2. Verify workspace has proper configuration files (go.mod, package.json, etc.)
3. Try running the LSP server manually to see errors
---
### 1203 - ErrLSPTimeout
**Description**: LSP operation exceeded timeout.
**Context Fields**:
- `operation`: The LSP method that timed out
- `duration`: How long the operation ran
**Common Causes**:
- LSP server indexing large project
- Complex type analysis
- Server deadlock
**Remediation**:
1. Wait for LSP server to complete initial indexing
2. Increase timeout via `MCP_LSP_TIMEOUT`:
```bash
export MCP_LSP_TIMEOUT="10m"
```
3. Restart the MCP server to restart LSP servers
---
### 1204 - ErrLSPCommunication
**Description**: Communication error with LSP server.
**Context Fields**:
- `error`: Specific communication error
**Common Causes**:
- LSP server crashed
- Broken pipe (server terminated)
- Protocol version mismatch
**Remediation**:
1. Restart the MCP server
2. Update LSP server to latest version
3. Check for known issues with your LSP server version
---
### 1205 - ErrNoHoverInfo
**Description**: No hover information available at the requested position.
**Note**: Not all positions have hover information (e.g., whitespace, keywords).
---
### 1206 - ErrNoDefinition
**Description**: No definition found for the symbol at the requested position.
**Common Causes**:
- Position is not on a symbol
- Symbol is a built-in
- External dependency without source
---
### 1207 - ErrNoReferences
**Description**: No references found for the symbol.
**Note**: A symbol may have no references if unused.
---
## Edit Errors (1300-1399)
### 1301 - ErrEditFailed
**Description**: General edit operation failure.
**Context Fields**:
- `file`: Path to the file
- `operation`: The edit operation attempted
- `error`: Specific error details
---
### 1302 - ErrInvalidEdit
**Description**: The edit request is malformed or invalid.
**Context Fields**:
- `message`: Specific validation failure
**Common Causes**:
- Missing required fields
- Invalid operation type
- Missing `new_content` for replace/insert
**Valid Operations**:
- `replace` - Replace selected content
- `insert_before` - Insert before selection
- `insert_after` - Insert after selection
- `delete` - Delete selected content
**Remediation**:
Review the edit request and ensure all required fields are provided.
---
### 1303 - ErrFileNotFound
**Description**: The target file does not exist.
**Context Fields**:
- `file`: Path that was not found
**Remediation**:
1. Verify the file path is correct
2. Check for typos in the path
3. Ensure the file exists in the workspace
---
### 1304 - ErrFileNotReadable
**Description**: The file exists but cannot be read.
**Context Fields**:
- `file`: Path to the file
- `error`: System error details
**Common Causes**:
- Permission denied
- File locked by another process
- File is a directory
**Remediation**:
1. Check file permissions
2. Close other programs using the file
3. On macOS, ensure terminal has disk access
---
### 1305 - ErrFileNotWritable
**Description**: Cannot write to the file.
**Context Fields**:
- `file`: Path to the file
- `error`: System error details
**Common Causes**:
- Permission denied
- Read-only filesystem
- Disk full
- File locked
**Remediation**:
1. Check file and directory permissions
2. Verify disk space
3. Close other programs using the file
---
### 1306 - ErrNodeNotFound
**Description**: No AST node matches the selector criteria.
**Context Fields**:
- `selector`: Description of the selector criteria
**Common Causes**:
- Selector doesn't match any code
- Wrong node kind specified
- Name doesn't exist in file
**Remediation**:
1. Use `file_read` with `include_ast: true` to see available symbols
2. Verify selector criteria (kind, name, pattern, line)
3. Check spelling of symbol names
---
### 1307 - ErrValidationFailed
**Description**: Edit would produce invalid syntax.
**Context Fields**:
- `file`: Path to the file
- `error`: Syntax error details
**Common Causes**:
- `new_content` has syntax errors
- Edit breaks surrounding code structure
**Remediation**:
1. Validate `new_content` syntax independently
2. Ensure the edit maintains valid syntax
---
### 1308 - ErrInvalidSelection
**Description**: Selector matches multiple nodes or is ambiguous.
**Context Fields**:
- `message`: Details about the ambiguity
**Common Causes**:
- Text/pattern matches multiple locations
- Multiple functions with same name
**Remediation**:
1. Add `selector_index` to choose specific match
2. Add more selector criteria (kind, name, line)
3. Use line number to narrow selection
---
## Query Errors (1400-1499)
### 1401 - ErrInvalidQuery
**Description**: The AST query is malformed.
**Context Fields**:
- `query`: The invalid query
- `error`: Specific parsing error
**Remediation**:
Review query syntax. Valid patterns use:
- `$NAME` for single captures
- `$$$ARGS` for multiple items
- `$_` for wildcards
---
### 1402 - ErrQueryTimeout
**Description**: AST query exceeded timeout.
**Context Fields**:
- `query`: The query that timed out
- `duration`: How long it ran
**Remediation**:
1. Use more specific patterns
2. Narrow search paths
3. Reduce `max_results`
---
### 1403 - ErrNoMatches
**Description**: Query executed successfully but found no matches.
**Note**: Informational, not necessarily an error.
---
### 1404 - ErrQueryCompile
**Description**: Failed to compile the query pattern.
**Context Fields**:
- `pattern`: The invalid pattern
- `error`: Compilation error
---
## Config Errors (1500-1599)
### 1501 - ErrInvalidConfig
**Description**: Configuration file or value is invalid.
**Context Fields**:
- `field`: The invalid field
- `value`: The invalid value
- `error`: Specific validation error
**Remediation**:
Review `.mcp-filepuff.json` configuration file syntax and values.
---
### 1502 - ErrPathNotAllowed
**Description**: Requested path is outside the workspace root.
**Context Fields**:
- `path`: The requested path
- `workspace`: The workspace root
**Security Note**: This prevents path traversal attacks.
**Remediation**:
1. Ensure all paths are within workspace
2. Don't use `..` to escape workspace
3. Configure workspace root appropriately
---
### 1503 - ErrWorkspaceNotSet
**Description**: Workspace root is not configured.
**Remediation**:
Start the server with `-workspace` flag:
```bash
mcp-filepuff -workspace /path/to/workspace
```
---
## Internal Errors (1900-1999)
### 1900 - ErrInternal
**Description**: Unexpected internal error.
**Context Fields**:
- `error`: Error details
- `stack`: Stack trace (in debug mode)
**Remediation**:
1. Check server logs
2. Report issue if reproducible
---
### 1901 - ErrCacheFailed
**Description**: Cache operation failed.
**Context Fields**:
- `operation`: Cache operation that failed
- `error`: Specific error
---
### 1902 - ErrConcurrency
**Description**: Concurrency-related error (race condition, deadlock).
**Context Fields**:
- `operation`: Operation that failed
- `error`: Specific error
---
## Common Error Scenarios
### Scenario 1: New Project Setup
**Symptoms**: LSP features not working, errors like "LSP server not found"
**Solution**:
1. Install required language servers
2. Initialize project (e.g., `go mod init`, `npm init`)
3. Restart MCP server
### Scenario 2: Edit Fails with Validation Error
**Symptoms**: Edit operation rejected with syntax error
**Solution**:
1. Validate `new_content` is syntactically correct
2. Check that surrounding code structure is maintained
### Scenario 3: Search Returns Too Many Results
**Symptoms**: Search timeout or truncated results
**Solution**:
1. Use more specific patterns
2. Add file type filters: `"file_types": ["go"]`
3. Limit search paths
4. Set `max_results` limit
### Scenario 4: File Access Errors
**Symptoms**: Permission denied, file not readable
**Solution**:
1. Check file permissions: `ls -la <file>`
2. On macOS, grant disk access to terminal
3. Close programs locking the file
4. Run server with appropriate user permissions
---
## See Also
- [API.md](API.md) - Complete API reference
- [PERFORMANCE.md](PERFORMANCE.md) - Performance tuning guide
- [README.md](../README.md) - Getting started
+404
View File
@@ -0,0 +1,404 @@
# MCP Filepuff Performance Tuning Guide
This guide provides detailed information on optimizing mcp-filepuff performance, understanding resource usage, and configuring the server for your workload.
## Table of Contents
- [Parser Cache Configuration](#parser-cache-configuration)
- [File Size Limits](#file-size-limits)
- [LSP Configuration](#lsp-configuration)
- [Memory Usage Patterns](#memory-usage-patterns)
- [Benchmarking](#benchmarking)
- [Production Recommendations](#production-recommendations)
---
## Parser Cache Configuration
The parser cache is critical for performance as it avoids re-parsing files that haven't changed.
### How the Cache Works
```
┌─────────────────────────────────────────────────────────────┐
│ Parse Request │
│ (file, content) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Content Hash Check │
│ (xxHash64) │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
Cache Hit Cache Miss
│ │
▼ ▼
Return cached tree Parse with Tree-sitter
│ │
│ ▼
│ Store in LRU cache
│ │
└───────────┬───────────┘
│
▼
Return ParseResult
```
### Cache Statistics
The parser tracks detailed statistics:
```go
type CacheStatsResult struct {
Hits int64 // Number of cache hits
Misses int64 // Number of cache misses
HitRate float64 // Ratio of hits to total requests
Size int // Current number of cached items
TotalParseTime int64 // Total time spent parsing (nanoseconds)
ParseCount int64 // Number of parse operations
AvgParseTime int64 // Average parse time (nanoseconds)
LastParseTime int64 // Most recent parse duration
}
```
### Cache Configuration
The LRU cache holds up to **100 parsed AST trees** by default. This is sufficient for most development workflows where you interact with a subset of files.
**Cache Key**: xxHash64 of file content (extremely fast, ~5GB/s)
**Eviction Policy**: Least Recently Used (LRU) - when the cache is full, the least recently accessed entry is evicted.
### Optimizing Cache Performance
1. **Batch Related Operations**: When working on related files, perform all operations on one file before moving to the next. This maximizes cache hits.
2. **Monitor Hit Rate**: A healthy cache has >80% hit rate. Lower rates suggest:
- Working with too many files simultaneously
- Files changing frequently between operations
3. **Cache Invalidation**: The cache is content-based (hash), so modified files automatically get re-parsed.
---
## File Size Limits
### Default Limits
| Limit | Default Value | Environment Variable |
|-------|---------------|---------------------|
| Max File Size | 10 MB | - |
| Max Parse Size | 10 MB | - |
| Max Edit Size | 100 KB | - |
| Max Search Results | 1000 | - |
### Configuration
Configure via `.mcp-filepuff.json` in workspace root:
```json
{
"max_file_size": 10485760,
"max_parse_size": 10485760,
"max_search_results": 1000,
"max_edit_size": 102400
}
```
### Understanding Limits
**Max File Size (10 MB)**
- Maximum file size that can be read via `file_read`
- Prevents memory exhaustion with large files
- Increase for codebases with large generated files
**Max Parse Size (10 MB)**
- Maximum file size for AST parsing
- Tree-sitter parsing memory usage is ~3-5x file size
- A 10 MB file needs ~30-50 MB RAM for parsing
**Max Edit Size (100 KB)**
- Maximum size for files being edited
- Keeps diff generation fast
- Prevents accidental edits to large generated files
### Token-Efficient Reading
For large files, use token-efficient options:
```json
// Get only symbol summary (~90-98% token reduction)
{"path": "large_file.go", "include_ast": true, "symbols_only": true}
// Limit output lines
{"path": "large_file.go", "max_lines": 50}
// Read specific line range
{"path": "large_file.go", "line_start": 100, "line_end": 150}
```
---
## LSP Configuration
### Timeout Configuration
```bash
# LSP operation timeout (default: 5 minutes)
export MCP_LSP_TIMEOUT="5m"
# Search timeout (default: 30 seconds)
export MCP_SEARCH_TIMEOUT="30s"
```
### LSP Server Lifecycle
```
┌─────────────────────────────────────────────────────────────┐
│ LSP Request │
│ (hover, definition, references) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Check Server Pool │
│ (by language) │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
Server Exists No Server
│ │
▼ ▼
Update lastUsed Start New Server
│ │
│ ▼
│ Initialize (handshake)
│ │
└───────────┬───────────┘
│
▼
┌─────────────────┐
│ Open Document │
│ (if not open) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Execute LSP │
│ Request │
└────────┬────────┘
│
▼
Return Result
```
### Server Pool Management
- **Idle Timeout**: 5 minutes (servers closed after inactivity)
- **Pool Reaper**: Checks every 60 seconds for idle servers
- **One Server Per Language**: Efficient resource usage
### Optimizing LSP Performance
1. **First Request Latency**: Initial LSP requests are slow due to server startup and project indexing. Subsequent requests are fast.
2. **gopls Optimization**: For Go projects, gopls performance depends on module cache:
```bash
# Pre-populate module cache
go mod download
```
3. **typescript-language-server**: Ensure `node_modules` is populated:
```bash
npm install
```
4. **clangd**: Requires `compile_commands.json` for best results:
```bash
# Generate with CMake
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .
```
---
## Memory Usage Patterns
### Component Memory Usage
| Component | Memory Pattern | Notes |
|-----------|---------------|-------|
| Parser Registry | Per-language parsers | ~5-10 MB per language |
| AST Cache | LRU, 100 entries max | ~50-200 MB typically |
| LSP Servers | External processes | ~100-500 MB per server |
| Search (ripgrep) | Streaming | Minimal memory |
| Edit Engine | Per-operation | Proportional to file size |
### Memory Calculation Example
For a typical Go project:
```
Base Server: ~20 MB
Go Parser: ~10 MB
AST Cache (50 files): ~100 MB
gopls: ~300 MB
────────────────────────────────
Total: ~430 MB
```
### Reducing Memory Usage
1. **Disable LSP**: If you don't need go-to-definition/references:
```bash
export MCP_ENABLE_LSP="false"
```
This saves ~100-500 MB per language server.
2. **Reduce Cache Size**: For memory-constrained environments, you can recompile with a smaller cache size (requires code change).
3. **Close Idle Servers**: LSP servers are automatically closed after 5 minutes of inactivity.
---
## Benchmarking
### Running Benchmarks
The project includes comprehensive benchmarks:
```bash
# Run all benchmarks
go test -bench=. ./...
# Run parser benchmarks with memory stats
go test -bench=. -benchmem ./internal/parser/...
# Run with specific count for stability
go test -bench=. -count=5 ./internal/parser/...
```
### Available Benchmarks
**Parser Benchmarks** (`internal/parser/parser_bench_test.go`):
- `BenchmarkParseGo` - Go file parsing
- `BenchmarkParseTypeScript` - TypeScript file parsing
- `BenchmarkParsePython` - Python file parsing
- `BenchmarkParseC` - C file parsing
- `BenchmarkParseCpp` - C++ file parsing
- `BenchmarkCacheHit` - Cache hit performance
- `BenchmarkCacheMiss` - Cache miss performance
- `BenchmarkContentHash` - xxHash performance
- `BenchmarkExtractSymbols` - Symbol extraction
### Expected Performance
Typical benchmark results (M1 Mac):
```
BenchmarkParseGo-8 5000 220000 ns/op 45000 B/op 850 allocs/op
BenchmarkParseTypeScript-8 3000 380000 ns/op 62000 B/op 1200 allocs/op
BenchmarkCacheHit-8 500000 2400 ns/op 128 B/op 3 allocs/op
BenchmarkContentHash-8 2000000 600 ns/op 0 B/op 0 allocs/op
```
Key observations:
- Cache hits are **~100x faster** than cache misses
- Content hashing is extremely fast (xxHash64)
- Parsing speed varies by language complexity
### Profiling
```bash
# CPU profiling
go test -bench=BenchmarkParseGo -cpuprofile=cpu.prof ./internal/parser/...
go tool pprof cpu.prof
# Memory profiling
go test -bench=BenchmarkParseGo -memprofile=mem.prof ./internal/parser/...
go tool pprof mem.prof
# Generate flame graph (requires pprof)
go tool pprof -http=:8080 cpu.prof
```
---
## Production Recommendations
### Environment Variables
```bash
# Essential configuration
export MCP_WORKSPACE_ROOT="/path/to/workspace"
export MCP_LSP_TIMEOUT="5m"
export MCP_SEARCH_TIMEOUT="30s"
export MCP_ENABLE_LSP="true"
# Optional optimizations
export MCP_FOLLOW_SYMLINKS="true"
export MCP_RESPECT_GITIGNORE="true"
```
### Logging Configuration
```bash
# Development
./mcp-filepuff -log-level debug -log-file /tmp/mcp-filepuff.log
# Production (minimal logging)
./mcp-filepuff -log-level warn
```
### Health Monitoring
Use the `ping` tool to verify server health:
```json
{"tool": "ping"}
```
Expected response: `"pong"`
### Performance Checklist
- [ ] Language servers installed and in PATH
- [ ] Project initialized (go.mod, package.json, etc.)
- [ ] Reasonable file size limits for your codebase
- [ ] LSP timeout appropriate for project size
- [ ] Adequate system memory (recommend 2+ GB free)
### Troubleshooting Slow Performance
1. **Slow Initial Operations**
- LSP servers need to index project
- Wait for initial indexing to complete
- Check LSP server logs for progress
2. **Slow Search**
- Check for overly broad patterns
- Exclude large directories (node_modules, vendor)
- Verify .gitignore is respected
3. **High Memory Usage**
- Disable unused LSP servers
- Check for memory leaks in language servers
- Monitor cache size
4. **Timeouts**
- Increase timeout values
- Check for I/O bottlenecks
- Verify network filesystems are responsive
---
## See Also
- [API.md](API.md) - Complete API reference
- [ERROR_CODES.md](ERROR_CODES.md) - Error code reference
- [README.md](../README.md) - Getting started
+5 -7
View File
@@ -4,21 +4,19 @@ go 1.25.5
require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.5
github.com/goccy/go-json v0.10.6
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.43.2
github.com/mark3labs/mcp-go v0.50.0
github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.36.0 // indirect
)
+14 -16
View File
@@ -1,24 +1,22 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -26,14 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.50.0 h1:kfxh8ejrBCABy5pez+rUYpsSLUKV2Yg38xBtrGRuo4U=
github.com/mark3labs/mcp-go v0.50.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -44,10 +42,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+86 -28
View File
@@ -2,6 +2,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -12,40 +13,46 @@ import (
// Config holds all configuration options for the MCP server.
type Config struct {
Formatters map[string]string `json:"formatters"`
WorkspaceRoot string `json:"workspace_root"`
LSPTimeout time.Duration `json:"lsp_timeout"`
SearchTimeout time.Duration `json:"search_timeout"`
MaxFileSize int64 `json:"max_file_size"`
MaxSearchResults int `json:"max_search_results"`
MaxEditSize int64 `json:"max_edit_size"`
EnableLSP bool `json:"enable_lsp"`
FollowSymlinks bool `json:"follow_symlinks"`
RespectGitignore bool `json:"respect_gitignore"`
Formatters map[string]string `json:"formatters"`
WorkspaceRoot string `json:"workspace_root"`
LSPTimeout time.Duration `json:"lsp_timeout"`
SearchTimeout time.Duration `json:"search_timeout"`
MaxFileSize int64 `json:"max_file_size"`
MaxParseSize int64 `json:"max_parse_size"`
MaxSearchResults int `json:"max_search_results"`
MaxEditSize int64 `json:"max_edit_size"`
EnableLSP bool `json:"enable_lsp"`
FollowSymlinks bool `json:"follow_symlinks"`
RespectGitignore bool `json:"respect_gitignore"`
ResourceLinkThresholdBytes int `json:"resource_link_threshold_bytes"`
}
// Default values for configuration.
const (
DefaultLSPTimeout = 5 * time.Minute
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultLSPTimeout = 5 * time.Minute
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
)
// Default returns a Config with default values.
func Default() *Config {
return &Config{
WorkspaceRoot: ".",
LSPTimeout: DefaultLSPTimeout,
SearchTimeout: DefaultSearchTimeout,
MaxFileSize: DefaultMaxFileSize,
MaxSearchResults: DefaultMaxSearchResults,
MaxEditSize: DefaultMaxEditSize,
EnableLSP: true,
Formatters: make(map[string]string),
FollowSymlinks: true,
RespectGitignore: true,
WorkspaceRoot: ".",
LSPTimeout: DefaultLSPTimeout,
SearchTimeout: DefaultSearchTimeout,
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
MaxSearchResults: DefaultMaxSearchResults,
MaxEditSize: DefaultMaxEditSize,
EnableLSP: true,
Formatters: make(map[string]string),
FollowSymlinks: true,
RespectGitignore: true,
ResourceLinkThresholdBytes: DefaultResourceLinkThresholdBytes,
}
}
@@ -65,13 +72,26 @@ func Load(workspaceRoot string) (*Config, error) {
cfg.WorkspaceRoot = cwd
}
// Try to load from config file in workspace root
// Try to load from config file in workspace root.
// Save WorkspaceRoot before loading config file so it cannot be overridden.
savedRoot := cfg.WorkspaceRoot
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
// Restore WorkspaceRoot — config file must not override path guards.
cfg.WorkspaceRoot = savedRoot
// Clamp size limits to prevent config file from requesting excessive memory.
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
if cfg.MaxFileSize > maxAllowedSize {
cfg.MaxFileSize = maxAllowedSize
}
if cfg.MaxParseSize > maxAllowedSize {
cfg.MaxParseSize = maxAllowedSize
}
// Override from environment variables
cfg.loadFromEnv()
@@ -169,6 +189,44 @@ func (c *Config) IsPathAllowed(path string) bool {
// Check if the path is within workspace (doesn't start with ..)
// This prevents both "../" attacks and symlink bypasses
// Also reject empty relative path (which means it's the workspace root itself)
return rel != "." && !strings.HasPrefix(rel, "..")
// The workspace root itself (rel == ".") is a valid, allowed path
return !strings.HasPrefix(rel, "..")
}
// Validate validates the configuration and returns an error if invalid.
// Checks include:
// - MaxFileSize and MaxParseSize must be positive
// - LSPTimeout must be positive
// - WorkspaceRoot must exist (when not empty)
func (c *Config) Validate() error {
// Validate MaxFileSize
if c.MaxFileSize <= 0 {
return fmt.Errorf("max_file_size must be positive, got %d", c.MaxFileSize)
}
// Validate MaxParseSize
if c.MaxParseSize <= 0 {
return fmt.Errorf("max_parse_size must be positive, got %d", c.MaxParseSize)
}
// Validate LSPTimeout
if c.LSPTimeout <= 0 {
return fmt.Errorf("lsp_timeout must be positive, got %v", c.LSPTimeout)
}
// Validate WorkspaceRoot exists
if c.WorkspaceRoot != "" {
info, err := os.Stat(c.WorkspaceRoot)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("workspace_root does not exist: %s", c.WorkspaceRoot)
}
return fmt.Errorf("cannot access workspace_root: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("workspace_root is not a directory: %s", c.WorkspaceRoot)
}
}
return nil
}
+435 -4
View File
@@ -45,7 +45,7 @@ func TestLoad(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg, err := Load(tmpDir)
if err != nil {
@@ -108,7 +108,7 @@ func TestIsPathAllowed(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
@@ -156,7 +156,7 @@ func TestLoadWithConfigFile(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write config file
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
@@ -164,7 +164,7 @@ func TestLoadWithConfigFile(t *testing.T) {
"enable_lsp": false,
"follow_symlinks": false
}`
err = os.WriteFile(configPath, []byte(configContent), 0600)
err = os.WriteFile(configPath, []byte(configContent), 0o600)
if err != nil {
t.Fatalf("failed to write config file: %v", err)
}
@@ -182,3 +182,434 @@ func TestLoadWithConfigFile(t *testing.T) {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// TestValidate tests the Validate method with various inputs.
func TestValidate(t *testing.T) {
tests := []struct {
name string
cfg *Config
expectErr bool
errMsg string
}{
{
name: "valid_config",
cfg: Default(),
expectErr: false,
},
{
name: "invalid_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: -1,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "zero_max_file_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: 0,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_file_size must be positive",
},
{
name: "invalid_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: -1,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "zero_max_parse_size",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: 0,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "max_parse_size must be positive",
},
{
name: "invalid_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: -1 * time.Second,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "zero_lsp_timeout",
cfg: &Config{
WorkspaceRoot: ".",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: 0,
},
expectErr: true,
errMsg: "lsp_timeout must be positive",
},
{
name: "nonexistent_workspace",
cfg: &Config{
WorkspaceRoot: "/nonexistent/path/that/does/not/exist",
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
},
expectErr: true,
errMsg: "workspace_root does not exist",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
if tt.expectErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errMsg)
} else if !contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// TestValidateWithFile tests validation with an actual file as workspace root.
func TestValidateWithFile(t *testing.T) {
// Create a temporary file
tmpFile, err := os.CreateTemp("", "test-file-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
_ = tmpFile.Close()
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })
cfg := &Config{
WorkspaceRoot: tmpFile.Name(),
MaxFileSize: DefaultMaxFileSize,
MaxParseSize: DefaultMaxParseSize,
LSPTimeout: DefaultLSPTimeout,
}
err = cfg.Validate()
if err == nil {
t.Error("expected error when workspace_root is a file, got nil")
} else if !contains(err.Error(), "is not a directory") {
t.Errorf("expected error about not being a directory, got: %v", err)
}
}
// TestLoadEnvironmentPrecedence tests environment variable precedence.
func TestLoadEnvironmentPrecedence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write a config file with specific values
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
configContent := `{
"enable_lsp": false,
"follow_symlinks": false,
"lsp_timeout": 60000000000
}`
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Save and restore environment variables
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
t.Cleanup(func() {
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
})
// Set environment variables that should override config file
_ = os.Setenv("MCP_ENABLE_LSP", "false")
_ = os.Setenv("MCP_LSP_TIMEOUT", "2m")
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Environment variable should override config file
if cfg.LSPTimeout != 2*time.Minute {
t.Errorf("expected LSP timeout 2m from env, got %v", cfg.LSPTimeout)
}
if cfg.EnableLSP {
t.Error("expected EnableLSP to be false from env")
}
// Value from config file (not overridden by env)
if cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be false from config file")
}
}
// TestIsPathAllowedEdgeCases tests edge cases in path validation.
func TestIsPathAllowedEdgeCases(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := Default()
cfg.WorkspaceRoot = tmpDir
tests := []struct {
name string
path string
allowed bool
desc string
}{
{
name: "workspace_root_itself",
path: tmpDir,
allowed: true,
desc: "workspace root itself should be allowed",
},
{
name: "dot_relative",
path: ".",
allowed: false,
desc: "current directory should not be allowed",
},
{
name: "empty_path",
path: "",
allowed: false,
desc: "empty path should not be allowed",
},
{
name: "path_with_double_dots",
path: filepath.Join(tmpDir, "..", filepath.Base(tmpDir), "file.txt"),
allowed: true,
desc: "path with .. that resolves back inside workspace should be allowed",
},
{
name: "deeply_nested_valid",
path: filepath.Join(tmpDir, "a", "b", "c", "file.txt"),
allowed: true,
desc: "deeply nested path should be allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cfg.IsPathAllowed(tt.path)
if result != tt.allowed {
t.Errorf("%s: IsPathAllowed(%q) = %v, want %v", tt.desc, tt.path, result, tt.allowed)
}
})
}
}
// TestIsPathAllowedWithSymlinks tests path validation with symbolic links.
func TestIsPathAllowedWithSymlinks(t *testing.T) {
// Create temporary directories
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
realDir := filepath.Join(tmpDir, "real")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatalf("failed to create real dir: %v", err)
}
// Create a symlink inside workspace
symlinkPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(realDir, symlinkPath); err != nil {
t.Skip("symlink creation not supported on this system")
}
cfg := Default()
cfg.WorkspaceRoot = tmpDir
// File accessed through symlink should be allowed
fileViaSymlink := filepath.Join(symlinkPath, "test.txt")
if !cfg.IsPathAllowed(fileViaSymlink) {
t.Error("file accessed through symlink inside workspace should be allowed")
}
// Direct access should also work
fileDirect := filepath.Join(realDir, "test.txt")
if !cfg.IsPathAllowed(fileDirect) {
t.Error("file accessed directly should be allowed")
}
}
// TestLoadDefaultValues tests that default values are properly set.
func TestLoadDefaultValues(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Clear any environment variables that might affect defaults
origVars := []struct{ key, val string }{
{"MCP_ENABLE_LSP", os.Getenv("MCP_ENABLE_LSP")},
{"MCP_FOLLOW_SYMLINKS", os.Getenv("MCP_FOLLOW_SYMLINKS")},
{"MCP_RESPECT_GITIGNORE", os.Getenv("MCP_RESPECT_GITIGNORE")},
}
t.Cleanup(func() {
for _, v := range origVars {
_ = os.Setenv(v.key, v.val)
}
})
for _, v := range origVars {
_ = os.Unsetenv(v.key)
}
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify all default values
if cfg.LSPTimeout != DefaultLSPTimeout {
t.Errorf("expected LSPTimeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
}
if cfg.SearchTimeout != DefaultSearchTimeout {
t.Errorf("expected SearchTimeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
}
if cfg.MaxFileSize != DefaultMaxFileSize {
t.Errorf("expected MaxFileSize %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
}
if cfg.MaxParseSize != DefaultMaxParseSize {
t.Errorf("expected MaxParseSize %d, got %d", DefaultMaxParseSize, cfg.MaxParseSize)
}
if cfg.MaxSearchResults != DefaultMaxSearchResults {
t.Errorf("expected MaxSearchResults %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
}
if cfg.MaxEditSize != DefaultMaxEditSize {
t.Errorf("expected MaxEditSize %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
}
if !cfg.EnableLSP {
t.Error("expected EnableLSP to be true by default")
}
if !cfg.FollowSymlinks {
t.Error("expected FollowSymlinks to be true by default")
}
if !cfg.RespectGitignore {
t.Error("expected RespectGitignore to be true by default")
}
if cfg.Formatters == nil {
t.Error("expected Formatters map to be initialized")
}
}
// TestConfigFileLoadingErrors tests error handling during config file loading.
func TestConfigFileLoadingErrors(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Write invalid JSON
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
invalidJSON := `{"enable_lsp": invalid_value}`
if err := os.WriteFile(configPath, []byte(invalidJSON), 0o600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
_, err = Load(tmpDir)
if err == nil {
t.Error("expected error when loading invalid JSON config file")
}
}
// TestIsPathAllowed_SymlinkOutsideWorkspace verifies that symlinks pointing
// outside the workspace are rejected (T-01).
func TestIsPathAllowed_SymlinkOutsideWorkspace(t *testing.T) {
// Create two separate temp dirs: one as workspace, one as outside target
workspace, err := os.MkdirTemp("", "mcp-workspace-*")
if err != nil {
t.Fatalf("failed to create workspace dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(workspace) })
outside, err := os.MkdirTemp("", "mcp-outside-*")
if err != nil {
t.Fatalf("failed to create outside dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(outside) })
// Create a file outside the workspace
outsideFile := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("failed to write outside file: %v", err)
}
// Create a symlink inside the workspace pointing outside
symlinkPath := filepath.Join(workspace, "escape-link")
if err := os.Symlink(outsideFile, symlinkPath); err != nil {
t.Skip("symlink creation not supported on this system")
}
cfg := Default()
cfg.WorkspaceRoot = workspace
// The symlink resolves to a file outside workspace — must be rejected
if cfg.IsPathAllowed(symlinkPath) {
t.Error("symlink pointing outside workspace should NOT be allowed")
}
// Direct access to the outside file should also be rejected
if cfg.IsPathAllowed(outsideFile) {
t.Error("file outside workspace should NOT be allowed")
}
// File inside workspace should still be allowed
insideFile := filepath.Join(workspace, "safe.txt")
if !cfg.IsPathAllowed(insideFile) {
t.Error("file inside workspace should be allowed")
}
// Workspace root itself should be allowed (C-08 fix)
if !cfg.IsPathAllowed(workspace) {
t.Error("workspace root itself should be allowed")
}
}
// Helper function to check if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+1 -1
View File
@@ -126,7 +126,7 @@ func TestIsPathAllowed_BasicCases(t *testing.T) {
{
name: "workspace root itself",
path: tmpDir,
expected: false, // Empty relative path
expected: true, // Workspace root is a valid, allowed path (needed for ast_query)
},
}
+65
View File
@@ -0,0 +1,65 @@
// Package cursor implements opaque pagination cursors for MCP tools.
// A cursor encodes an offset into a result stream plus a query hash so stale
// cursors from different queries fail cleanly.
//
// Encoding: base64url(json({"offset":N,"query_hash":"hex"}))
// The query_hash is a hex-encoded sha256 over the deterministic query params.
package cursor
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"sort"
"strings"
json "github.com/goccy/go-json"
)
// payload is the JSON structure inside a cursor.
type payload struct {
Offset int `json:"offset"`
QueryHash string `json:"query_hash"`
}
// Encode creates an opaque cursor string from an offset and query hash.
func Encode(offset int, queryHash string) string {
p := payload{Offset: offset, QueryHash: queryHash}
b, _ := json.Marshal(p)
return base64.RawURLEncoding.EncodeToString(b)
}
// Decode parses a cursor string. Returns offset, queryHash, error.
func Decode(cursor string) (int, string, error) {
b, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, "", fmt.Errorf("invalid cursor encoding: %w", err)
}
var p payload
if err := json.Unmarshal(b, &p); err != nil {
return 0, "", fmt.Errorf("invalid cursor payload: %w", err)
}
return p.Offset, p.QueryHash, nil
}
// HashParams computes a deterministic query hash from a set of key=value params.
// Keys are sorted before hashing so order doesn't matter.
func HashParams(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(params[k])
sb.WriteByte('\n')
}
sum := sha256.Sum256([]byte(sb.String()))
return hex.EncodeToString(sum[:])
}
+57
View File
@@ -0,0 +1,57 @@
package cursor
import (
"testing"
)
func TestEncodeDecodRoundTrip(t *testing.T) {
hash := HashParams(map[string]string{"a": "1", "b": "2"})
encoded := Encode(42, hash)
if encoded == "" {
t.Fatal("Encode returned empty string")
}
offset, gotHash, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode error: %v", err)
}
if offset != 42 {
t.Errorf("offset: got %d, want 42", offset)
}
if gotHash != hash {
t.Errorf("hash mismatch: got %s, want %s", gotHash, hash)
}
}
func TestDecodeInvalid(t *testing.T) {
_, _, err := Decode("!!!notbase64!!!")
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
}
func TestDecodeCorruptPayload(t *testing.T) {
import64 := "bm90anNvbg" // "notjson" in base64
_, _, err := Decode(import64)
if err == nil {
t.Error("expected error for corrupt payload, got nil")
}
}
func TestHashParamsDeterministic(t *testing.T) {
// Same params regardless of insertion order
h1 := HashParams(map[string]string{"z": "last", "a": "first"})
h2 := HashParams(map[string]string{"a": "first", "z": "last"})
if h1 != h2 {
t.Errorf("hash not deterministic: %s != %s", h1, h2)
}
}
func TestHashParamsDifferentForDifferentQueries(t *testing.T) {
h1 := HashParams(map[string]string{"pattern": "foo"})
h2 := HashParams(map[string]string{"pattern": "bar"})
if h1 == h2 {
t.Error("different queries should produce different hashes")
}
}
+282 -135
View File
@@ -6,37 +6,18 @@ import (
"context"
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/sergi/go-diff/diffmatchpatch"
sitter "github.com/smacker/go-tree-sitter"
)
// Global regex cache for compiled patterns (thread-safe)
var regexCache sync.Map // string -> *regexp.Regexp
// compileRegex compiles a regex pattern with caching for performance.
func compileRegex(pattern string) (*regexp.Regexp, error) {
// Check cache first
if cached, ok := regexCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
// Compile and cache
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
regexCache.Store(pattern, re)
return re, nil
}
// EditOperation defines the type of edit operation.
type EditOperation string
@@ -80,6 +61,7 @@ type EditResult struct {
// Engine performs AST-aware edits.
type Engine struct {
registry *parser.Registry
dmp *diffmatchpatch.DiffMatchPatch
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
}
@@ -87,6 +69,7 @@ type Engine struct {
func NewEngine(registry *parser.Registry) *Engine {
return &Engine{
registry: registry,
dmp: diffmatchpatch.New(),
fileLocks: sync.Map{},
}
}
@@ -186,19 +169,24 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
}
// Generate diff
diff := generateDiff(string(content), string(newContent), edit.File)
diff := e.generateDiff(string(content), string(newContent), edit.File)
result := &EditResult{
Success: true,
Diff: diff,
OriginalContent: string(content),
NewContent: string(newContent),
Applied: false,
Success: true,
Diff: diff,
Applied: false,
}
// Apply changes if requested
if apply {
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
// 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 {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -238,19 +226,24 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
}
// Generate diff
diff := generateDiff(string(content), string(newContent), edit.File)
diff := e.generateDiff(string(content), string(newContent), edit.File)
result := &EditResult{
Success: true,
Diff: diff,
OriginalContent: string(content),
NewContent: string(newContent),
Applied: false,
Success: true,
Diff: diff,
Applied: false,
}
// Apply changes if requested
if apply {
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
// 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 {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -319,7 +312,7 @@ func (e *Engine) validateTextEdit(edit *ASTEdit) error {
// Validate regex pattern if provided (uses cached compilation)
if edit.Selector.TextPattern != "" {
if _, err := compileRegex(edit.Selector.TextPattern); err != nil {
if _, err := util.CompileRegex(edit.Selector.TextPattern); err != nil {
return errors.Wrap(errors.ErrInvalidEdit, "invalid text_pattern regex", err)
}
}
@@ -352,6 +345,12 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return nil, errors.NewNodeNotFoundError(selectorDesc)
}
// When using AtLine without a specific Kind, prefer the smallest (most specific) node.
// This prevents matching large parent nodes like source_file when we want a specific declaration.
if sel.AtLine > 0 && sel.Kind == "" {
matches = sortBySpecificity(matches)
}
// Use index to select specific match
index := sel.Index
if index < 0 || index >= len(matches) {
@@ -361,6 +360,77 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return matches[index], nil
}
// sortBySpecificity sorts nodes so that the most useful nodes come first.
// Prefers: 1) Named nodes (declarations/statements) over anonymous tokens
// 2) Smaller nodes over larger ones (more specific)
func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
if len(nodes) <= 1 {
return nodes
}
result := make([]*sitter.Node, len(nodes))
copy(result, nodes)
slices.SortFunc(result, func(a, b *sitter.Node) int {
if shouldPrefer(a, b) {
return -1
}
if shouldPrefer(b, a) {
return 1
}
return 0
})
return result
}
// shouldPrefer returns true if node a should come before node b.
func shouldPrefer(a, b *sitter.Node) bool {
// Prefer named nodes over anonymous tokens
aIsNamed := a.IsNamed()
bIsNamed := b.IsNamed()
if aIsNamed && !bIsNamed {
return true
}
if !aIsNamed && bIsNamed {
return false
}
// Both named or both anonymous: prefer smaller meaningful nodes
// But filter out very small nodes (likely just identifiers/literals)
aSize := a.EndByte() - a.StartByte()
bSize := b.EndByte() - b.StartByte()
// If both are named, prefer "declaration" or "statement" types
aIsDecl := isDeclarationLike(a.Type())
bIsDecl := isDeclarationLike(b.Type())
if aIsDecl && !bIsDecl {
return true
}
if !aIsDecl && bIsDecl {
return false
}
// Same category: prefer smaller
return aSize < bSize
}
// isDeclarationLike returns true for node types that represent declarations or statements.
func isDeclarationLike(nodeType string) bool {
// Common declaration/statement patterns across languages
declarationPatterns := []string{
"declaration", "definition", "statement", "spec", "clause",
"function", "method", "class", "struct", "interface", "type",
"import", "package", "module", "const", "var", "let",
}
for _, pattern := range declarationPatterns {
if strings.Contains(nodeType, pattern) {
return true
}
}
return false
}
// matchesSelector checks if a node matches the selector criteria.
func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte) bool {
// Check kind
@@ -412,13 +482,12 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
}
// applyEdit applies the edit operation to the content.
// AST mode uses exact byte positions — new_content is inserted verbatim without auto-indentation.
func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
startByte := node.StartByte()
endByte := node.EndByte()
// Detect and preserve indentation
indentation := detectIndentation(content, startByte)
newContent := indentContent(edit.NewContent, indentation)
newContent := edit.NewContent
var result []byte
@@ -426,18 +495,34 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]
case EditReplace:
result = append(result, content[:startByte]...)
result = append(result, []byte(newContent)...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
// re-add it to prevent line merging
if endByte > startByte && content[endByte-1] == '\n' && !strings.HasSuffix(newContent, "\n") {
result = append(result, '\n')
}
result = append(result, content[endByte:]...)
case EditInsertBefore:
insertion := newContent
if !strings.HasSuffix(insertion, "\n") {
insertion += "\n"
}
result = append(result, content[:startByte]...)
result = append(result, []byte(newContent)...)
result = append(result, '\n')
result = append(result, []byte(insertion)...)
result = append(result, content[startByte:]...)
case EditInsertAfter:
insertion := newContent
// Ensure separation from preceding content
if endByte > 0 && content[endByte-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
insertion = "\n" + insertion
}
// Ensure separation from following content
if !strings.HasSuffix(insertion, "\n") && endByte < uint32(len(content)) && content[endByte] != '\n' {
insertion += "\n"
}
result = append(result, content[:endByte]...)
result = append(result, '\n')
result = append(result, []byte(newContent)...)
result = append(result, []byte(insertion)...)
result = append(result, content[endByte:]...)
case EditDelete:
@@ -452,16 +537,16 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]
}
// detectIndentation detects the indentation at a given byte position.
func detectIndentation(content []byte, bytePos uint32) string {
func detectIndentation(content []byte, bytePos int) string {
// Find the start of the line
lineStart := int(bytePos)
lineStart := bytePos
for lineStart > 0 && content[lineStart-1] != '\n' {
lineStart--
}
// Extract leading whitespace
var indent strings.Builder
for i := lineStart; i < int(bytePos) && i < len(content); i++ {
for i := lineStart; i < bytePos && i < len(content); i++ {
c := content[i]
if c == ' ' || c == '\t' {
indent.WriteByte(c)
@@ -489,38 +574,140 @@ func indentContent(content string, indent string) string {
return strings.Join(lines, "\n")
}
// diffLine represents a single line in the diff with its type and content.
type diffLine struct {
op diffmatchpatch.Operation
text string // line content without trailing newline
oldN int // 1-based line number in original (0 if insert)
newN int // 1-based line number in modified (0 if delete)
}
// generateDiff creates a unified diff between original and modified content.
// Uses Myers diff algorithm for accurate and readable diffs.
func generateDiff(original, modified, filename string) string {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(original, modified, false)
// Uses line-level Myers diff algorithm and outputs a proper unified diff
// with context lines (3 before/after each change, merging close hunks).
func (e *Engine) generateDiff(original, modified, filename string) string {
dmp := e.dmp
// Use line-level diffing: encode each line as a single character,
// diff the encoded strings, then decode back to real lines.
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
diffs := dmp.DiffMain(chars1, chars2, false)
diffs = dmp.DiffCharsToLines(diffs, lineArray)
// Cleanup for readability
diffs = dmp.DiffCleanupSemantic(diffs)
// Convert to unified diff format
// Flatten diffs into individual lines with line numbers
var lines []diffLine
oldLine := 1
newLine := 1
for _, d := range diffs {
rawLines := strings.SplitAfter(d.Text, "\n")
for _, raw := range rawLines {
if raw == "" {
continue
}
text := strings.TrimSuffix(raw, "\n")
switch d.Type {
case diffmatchpatch.DiffEqual:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
oldLine++
newLine++
case diffmatchpatch.DiffDelete:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine})
oldLine++
case diffmatchpatch.DiffInsert:
lines = append(lines, diffLine{op: d.Type, text: text, newN: newLine})
newLine++
}
}
}
// Identify indices of changed lines
const contextSize = 3
var changedIndices []int
for i, l := range lines {
if l.op != diffmatchpatch.DiffEqual {
changedIndices = append(changedIndices, i)
}
}
if len(changedIndices) == 0 {
return "" // no changes
}
// Build inclusion ranges: for each changed line, include contextSize lines before/after.
// Merge overlapping or adjacent ranges (gap <= 2*contextSize = 6 context lines).
type indexRange struct{ start, end int } // inclusive
var ranges []indexRange
for _, ci := range changedIndices {
rStart := ci - contextSize
if rStart < 0 {
rStart = 0
}
rEnd := ci + contextSize
if rEnd >= len(lines) {
rEnd = len(lines) - 1
}
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
// Merge with previous range
ranges[len(ranges)-1].end = rEnd
} else {
ranges = append(ranges, indexRange{rStart, rEnd})
}
}
// Emit unified diff
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
// Group diffs into hunks
lineNum := 1
for _, diff := range diffs {
lines := strings.Split(diff.Text, "\n")
for i, line := range lines {
// Skip empty last line from split
if i == len(lines)-1 && line == "" {
continue
}
switch diff.Type {
case diffmatchpatch.DiffDelete:
buf.WriteString(fmt.Sprintf("-%s\n", line))
case diffmatchpatch.DiffInsert:
buf.WriteString(fmt.Sprintf("+%s\n", line))
for _, r := range ranges {
// Determine hunk header line numbers
var oldStart, oldCount, newStart, newCount int
for i := r.start; i <= r.end; i++ {
l := lines[i]
switch l.op {
case diffmatchpatch.DiffEqual:
buf.WriteString(fmt.Sprintf(" %s\n", line))
lineNum++
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
newStart = l.newN
}
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
// Set newStart from context or next available
newStart = l.oldN // approximate
}
oldCount++
case diffmatchpatch.DiffInsert:
if newCount == 0 {
newStart = l.newN
}
if oldCount == 0 {
oldStart = l.newN // approximate
}
newCount++
}
}
buf.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount))
for i := r.start; i <= r.end; i++ {
l := lines[i]
switch l.op {
case diffmatchpatch.DiffEqual:
buf.WriteString(fmt.Sprintf(" %s\n", l.text))
case diffmatchpatch.DiffDelete:
buf.WriteString(fmt.Sprintf("-%s\n", l.text))
case diffmatchpatch.DiffInsert:
buf.WriteString(fmt.Sprintf("+%s\n", l.text))
}
}
}
@@ -569,24 +756,6 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text not found: %q", truncateString(text, 50)))
}
// If multiple matches and no index specified, require explicit selection
if len(matches) > 1 && index == 0 {
// Check if index was explicitly set to 0 or just defaulted
// Since we can't distinguish, we'll allow index 0 but warn about multiple matches
// Actually, let's be strict and require explicit index for multiple matches
locations := make([]string, 0, min(len(matches), 5))
for i, m := range matches {
if i >= 5 {
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
break
}
line := countLines(content[:m.start]) + 1
locations = append(locations, fmt.Sprintf("line %d", line))
}
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text matches %d locations (%s); use selector_index to specify which one (0-%d)",
len(matches), strings.Join(locations, ", "), len(matches)-1))
}
if index >= len(matches) {
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
}
@@ -596,7 +765,7 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
// findRegexPattern finds a regex pattern match in content.
func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (start, end int, err error) {
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
return 0, 0, errors.Wrap(errors.ErrInvalidEdit, "invalid regex pattern", err)
}
@@ -606,21 +775,6 @@ func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (st
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern not found: %q", truncateString(pattern, 50)))
}
// If multiple matches and index is 0 (default), show error with locations
if len(matches) > 1 && index == 0 {
locations := make([]string, 0, min(len(matches), 5))
for i, m := range matches {
if i >= 5 {
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
break
}
line := countLines(content[:m[0]]) + 1
locations = append(locations, fmt.Sprintf("line %d", line))
}
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern matches %d locations (%s); use selector_index to specify which one (0-%d)",
len(matches), strings.Join(locations, ", "), len(matches)-1))
}
if index >= len(matches) {
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
}
@@ -643,6 +797,10 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
}
lines := bytes.Split(content, []byte("\n"))
// Trim phantom empty element from trailing newline
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
lines = lines[:len(lines)-1]
}
totalLines := len(lines)
// Convert to 0-indexed
@@ -658,7 +816,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
// Calculate byte positions
start = 0
for i := 0; i < startIdx; i++ {
for i := range startIdx {
start += len(lines[i]) + 1 // +1 for newline
}
@@ -676,7 +834,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
// applyTextEditOperation applies a text edit operation.
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
// Detect indentation at the selection point
indentation := detectIndentationAtByte(content, start)
indentation := detectIndentation(content, start)
indentedContent := indentContent(newContent, indentation)
var result []byte
@@ -685,18 +843,34 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start,
case EditReplace:
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
// re-add it to prevent line merging
if end > start && content[end-1] == '\n' && !strings.HasSuffix(indentedContent, "\n") {
result = append(result, '\n')
}
result = append(result, content[end:]...)
case EditInsertBefore:
insertion := indentedContent
if !strings.HasSuffix(insertion, "\n") {
insertion += "\n"
}
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
result = append(result, '\n')
result = append(result, []byte(insertion)...)
result = append(result, content[start:]...)
case EditInsertAfter:
insertion := indentedContent
// Ensure separation from preceding content
if end > 0 && content[end-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
insertion = "\n" + insertion
}
// Ensure separation from following content
if !strings.HasSuffix(insertion, "\n") && end < len(content) && content[end] != '\n' {
insertion += "\n"
}
result = append(result, content[:end]...)
result = append(result, '\n')
result = append(result, []byte(indentedContent)...)
result = append(result, []byte(insertion)...)
result = append(result, content[end:]...)
case EditDelete:
@@ -710,28 +884,6 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start,
return result, nil
}
// detectIndentationAtByte detects indentation at a byte position.
func detectIndentationAtByte(content []byte, bytePos int) string {
// Find the start of the line
lineStart := bytePos
for lineStart > 0 && content[lineStart-1] != '\n' {
lineStart--
}
// Extract leading whitespace
var indent strings.Builder
for i := lineStart; i < bytePos && i < len(content); i++ {
c := content[i]
if c == ' ' || c == '\t' {
indent.WriteByte(c)
} else {
break
}
}
return indent.String()
}
// truncateString truncates a string to maxLen with ellipsis.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -740,11 +892,6 @@ func truncateString(s string, maxLen int) string {
return s[:maxLen-3] + "..."
}
// countLines counts the number of newlines in content.
func countLines(content []byte) int {
return bytes.Count(content, []byte("\n"))
}
// ValidateLanguage checks if AST editing is supported for a file.
// Returns nil for supported languages, error for unsupported.
// Note: Text-based editing is always available regardless of this check.
+884 -8
View File
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/sergi/go-diff/diffmatchpatch"
sitter "github.com/smacker/go-tree-sitter"
)
func TestValidateEdit(t *testing.T) {
@@ -356,7 +358,7 @@ func TestDetectIndentation(t *testing.T) {
name string
content string
want string
pos uint32
pos int
}{
{
name: "no indent",
@@ -393,7 +395,7 @@ func TestGenerateDiff(t *testing.T) {
modified := "line1\nmodified\nline3"
filename := "test.txt"
diff := generateDiff(original, modified, filename)
diff := (&Engine{dmp: diffmatchpatch.New()}).generateDiff(original, modified, filename)
if !strings.Contains(diff, "---") {
t.Error("diff should contain --- header")
@@ -409,6 +411,71 @@ func TestGenerateDiff(t *testing.T) {
}
}
func TestGenerateDiffLineLevelAccuracy(t *testing.T) {
// Regression test: diff must operate at line level, not character level.
// A character-level diff would split "hello" and "hello world" mid-line,
// producing broken output like:
// fmt.Println("hello
// + world
// ")
original := "package main\n\nfunc hello() {\n\tfmt.Println(\"hello\")\n}\n"
modified := "package main\n\nfunc hello() {\n\tfmt.Println(\"hello world\")\n}\n"
diff := (&Engine{dmp: diffmatchpatch.New()}).generateDiff(original, modified, "test.go")
// The diff must show whole-line removals and additions
if !strings.Contains(diff, "-\tfmt.Println(\"hello\")\n") {
t.Errorf("diff should show full removed line, got:\n%s", diff)
}
if !strings.Contains(diff, "+\tfmt.Println(\"hello world\")\n") {
t.Errorf("diff should show full added line, got:\n%s", diff)
}
// The diff must NOT split lines at character boundaries
if strings.Contains(diff, "+hello") && !strings.Contains(diff, "Println") {
t.Errorf("diff appears to be character-level (split mid-line), got:\n%s", diff)
}
// Context lines should not be marked as changed
for line := range strings.SplitSeq(diff, "\n") {
if strings.HasPrefix(line, "-") || strings.HasPrefix(line, "+") {
// Changed lines should only be the Println lines
if strings.Contains(line, "package main") ||
strings.Contains(line, "func hello()") {
t.Errorf("unchanged line incorrectly marked as changed: %q", line)
}
}
}
}
func TestGenerateDiffNoPhantomChanges(t *testing.T) {
// Regression test: replacing a line range should not produce phantom
// +/- lines for unchanged code after the edit region.
original := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n"
modified := "line1\nREPLACED\nline3\nline4\nline5\nline6\nline7\nline8\n"
diff := (&Engine{dmp: diffmatchpatch.New()}).generateDiff(original, modified, "test.txt")
// Count changed lines (excluding headers)
addCount := 0
delCount := 0
for line := range strings.SplitSeq(diff, "\n") {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
addCount++
}
if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
delCount++
}
}
if addCount != 1 {
t.Errorf("expected 1 added line, got %d. Diff:\n%s", addCount, diff)
}
if delCount != 1 {
t.Errorf("expected 1 deleted line, got %d. Diff:\n%s", delCount, diff)
}
}
// ==================== Text-based editing tests ====================
func TestTextEditWithExactText(t *testing.T) {
@@ -592,7 +659,7 @@ SECRET_KEY=abc123
}
}
func TestTextEditMultipleMatchesError(t *testing.T) {
func TestTextEditMultipleMatchesSelectsFirst(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
@@ -623,12 +690,19 @@ more code
t.Fatalf("apply failed: %v", err)
}
// Should fail because of multiple matches
if result.Success {
t.Error("expected error for multiple matches without index")
// Index 0 (default) should select the first match
if !result.Success {
t.Fatalf("expected success for multiple matches with default index 0: %s", result.Error)
}
if !strings.Contains(result.Error, "matches") {
t.Errorf("error should mention multiple matches: %s", result.Error)
// Verify only first TODO was replaced
fileContent, _ := os.ReadFile(tmpFile)
contentStr := string(fileContent)
if !strings.Contains(contentStr, "DONE: fix this") {
t.Error("first TODO should be replaced with DONE")
}
if !strings.Contains(contentStr, "TODO: also fix this") {
t.Error("second TODO should be unchanged")
}
}
@@ -754,6 +828,500 @@ func TestValidateTextEdit(t *testing.T) {
}
}
func TestResolveSelectorAtLineSpecificity(t *testing.T) {
// This test verifies that when using AtLine selector without Kind,
// the smallest (most specific) node is selected, not the largest parent.
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
// Create a Go file with nested structures
content := []byte(`package main
const (
// First const group
FOO = "foo"
BAR = "bar"
)
const (
// Second const group
BAZ = "baz"
QUX = "qux"
)
func main() {
println(FOO)
}
`)
ctx := context.Background()
result, err := registry.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// Selector at line 5 (FOO = "foo") should match the specific const_spec,
// not the entire const_declaration or source_file
node, err := e.resolveSelector(ASTSelector{AtLine: 5}, result.Tree, content)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
// The node should be small - just the "FOO = \"foo\"" part
nodeText := string(content[node.StartByte():node.EndByte()])
if strings.Contains(nodeText, "BAR") {
t.Errorf("selected node is too large (contains BAR): %q", nodeText)
}
if strings.Contains(nodeText, "package") {
t.Errorf("selected node is the entire file: %q", truncateString(nodeText, 50))
}
t.Logf("Selected node type: %s, text: %q", node.Type(), truncateString(nodeText, 100))
}
// ==================== Regression tests for file corruption bug ====================
// These tests verify that the fix for the AtLine selector specificity issue
// prevents file corruption when inserting content.
func TestRegressionInsertAfterAtLine(t *testing.T) {
// Regression test: Insert after a specific const should not corrupt the file
// by inserting at the wrong location (e.g., at start of file or wrong block)
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "queries.go")
// Simulate a typical Go queries file
content := `package org
const (
// queryGetOrg retrieves an organization by ID
queryGetOrg = ` + "`" + `
SELECT id, name FROM orgs WHERE id = $1
` + "`" + `
// queryListOrgs lists all organizations
queryListOrgs = ` + "`" + `
SELECT id, name FROM orgs
` + "`" + `
)
const (
// queryCreateOrg creates a new organization
queryCreateOrg = ` + "`" + `
INSERT INTO orgs (name) VALUES ($1)
` + "`" + `
)
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert after the first const block (line ~14) - should NOT corrupt file
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertAfter,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 5, // Line within the first const block
},
NewContent: `const (
// queryGetGitHub retrieves GitHub settings
queryGetGitHub = ` + "`" + `SELECT * FROM github` + "`" + `
)`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
// Verify the file structure is preserved
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Check the file still starts with package declaration
if !strings.HasPrefix(newStr, "package org") {
t.Errorf("file corrupted: package declaration missing or moved\nContent:\n%s", newStr)
}
// Check original content is preserved
if !strings.Contains(newStr, "queryGetOrg") {
t.Error("original queryGetOrg was lost")
}
if !strings.Contains(newStr, "queryListOrgs") {
t.Error("original queryListOrgs was lost")
}
if !strings.Contains(newStr, "queryCreateOrg") {
t.Error("original queryCreateOrg was lost")
}
// Check new content was added
if !strings.Contains(newStr, "queryGetGitHub") {
t.Error("new queryGetGitHub was not added")
}
// Verify insertion location: queryGetGitHub should appear AFTER queryListOrgs
// but the exact position depends on where the first const block ends
listOrgsIdx := strings.Index(newStr, "queryListOrgs")
gitHubIdx := strings.Index(newStr, "queryGetGitHub")
if gitHubIdx < listOrgsIdx {
t.Errorf("queryGetGitHub inserted before queryListOrgs - wrong location")
}
t.Logf("Result diff:\n%s", result.Diff)
}
func TestRegressionInsertBeforeAtLine(t *testing.T) {
// Regression test: Insert before should work correctly with AtLine
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "main.go")
content := `package main
import "fmt"
func hello() {
fmt.Println("hello")
}
func goodbye() {
fmt.Println("goodbye")
}
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert before goodbye function
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertBefore,
Selector: ASTSelector{
Kind: "function_declaration",
Name: "goodbye",
AtLine: 9,
},
NewContent: `func middle() {
fmt.Println("middle")
}`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify order: hello -> middle -> goodbye
helloIdx := strings.Index(newStr, "func hello()")
middleIdx := strings.Index(newStr, "func middle()")
goodbyeIdx := strings.Index(newStr, "func goodbye()")
if helloIdx == -1 || middleIdx == -1 || goodbyeIdx == -1 {
t.Fatalf("missing functions in output:\n%s", newStr)
}
if helloIdx >= middleIdx || middleIdx >= goodbyeIdx {
t.Errorf("functions in wrong order: hello=%d, middle=%d, goodbye=%d", helloIdx, middleIdx, goodbyeIdx)
}
}
func TestRegressionNestedStructures(t *testing.T) {
// Regression test: Nested structures should select the correct node
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "nested.go")
content := `package main
type Outer struct {
Inner struct {
Field string
}
Other int
}
func main() {
o := Outer{}
_ = o
}
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Replace the Inner struct field - should not affect Outer
edit := &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{
AtLine: 5, // Line with "Field string"
},
NewContent: `Name string`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify structure is preserved
if !strings.Contains(newStr, "type Outer struct") {
t.Error("Outer struct declaration lost")
}
if !strings.Contains(newStr, "Inner struct") {
t.Error("Inner struct declaration lost")
}
if !strings.Contains(newStr, "Other int") {
t.Error("Other field lost")
}
if !strings.Contains(newStr, "Name string") {
t.Error("Name field not added")
}
if strings.Contains(newStr, "Field string") {
t.Error("Old Field string should be replaced")
}
}
func TestRegressionPreservesFileIntegrity(t *testing.T) {
// Regression test: Edit should not corrupt unrelated parts of the file
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "integrity.go")
content := `package main
// Copyright 2024 Example Corp
// License: MIT
import (
"fmt"
"os"
)
const Version = "1.0.0"
func main() {
fmt.Println(Version)
os.Exit(0)
}
// End of file comment
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Replace the Version constant (line 11)
edit := &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 11,
},
NewContent: `const Version = "2.0.0"`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify all unrelated parts are preserved exactly
checks := []string{
"package main",
"// Copyright 2024 Example Corp",
"// License: MIT",
`"fmt"`,
`"os"`,
"func main()",
"fmt.Println(Version)",
"os.Exit(0)",
"// End of file comment",
}
for _, check := range checks {
if !strings.Contains(newStr, check) {
t.Errorf("file integrity violated: missing %q\nContent:\n%s", check, newStr)
}
}
// Verify the change was made
if !strings.Contains(newStr, `"2.0.0"`) {
t.Error("Version was not updated to 2.0.0")
}
}
func TestRegressionMultipleConstBlocks(t *testing.T) {
// Regression test: Multiple const blocks - edit should target correct one
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "consts.go")
content := `package main
const (
A = 1
B = 2
)
const (
C = 3
D = 4
)
const (
E = 5
F = 6
)
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert after the second const block (containing C, D)
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertAfter,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 9, // Line with C = 3
},
NewContent: `const X = 99`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// X should appear after D but before E
dIdx := strings.Index(newStr, "D = 4")
xIdx := strings.Index(newStr, "X = 99")
eIdx := strings.Index(newStr, "E = 5")
if xIdx == -1 {
t.Fatalf("X = 99 not found in output:\n%s", newStr)
}
if dIdx >= xIdx || xIdx >= eIdx {
t.Errorf("X inserted in wrong position: D=%d, X=%d, E=%d\nContent:\n%s", dIdx, xIdx, eIdx, newStr)
}
}
func TestSortBySpecificity(t *testing.T) {
// Unit test for the sortBySpecificity helper function
registry := parser.NewRegistry()
defer registry.Close()
content := []byte(`package main
const (
FOO = "foo"
)
`)
ctx := context.Background()
result, err := registry.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// Collect all nodes that span line 4
var nodesAtLine4 []*sitter.Node
parser.WalkTree(result.Tree.RootNode(), func(n *sitter.Node) bool {
startLine := int(n.StartPoint().Row) + 1
endLine := int(n.EndPoint().Row) + 1
if 4 >= startLine && 4 <= endLine {
nodesAtLine4 = append(nodesAtLine4, n)
}
return true
})
if len(nodesAtLine4) < 2 {
t.Skip("not enough nodes to test sorting")
}
// Sort and verify smallest declaration-like node comes first
sorted := sortBySpecificity(nodesAtLine4)
// First node should be a declaration-like node, not source_file
firstType := sorted[0].Type()
if firstType == "source_file" {
t.Errorf("source_file should not be first after sorting, got types: %v",
nodeTypes(sorted))
}
t.Logf("Sorted node types: %v", nodeTypes(sorted))
}
func nodeTypes(nodes []*sitter.Node) []string {
types := make([]string, len(nodes))
for i, n := range nodes {
types[i] = n.Type()
}
return types
}
func TestFindLineRange(t *testing.T) {
e := NewEngine(parser.NewRegistry())
@@ -834,3 +1402,311 @@ func TestFindLineRange(t *testing.T) {
})
}
}
// ==================== Line merge regression tests ====================
// These tests verify that edit operations don't merge adjacent lines.
// Each test checks exact line-by-line output, not just substring presence.
func TestRegressionLineMergeReplace(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tests := []struct {
name string
content string
edit *ASTEdit
wantLines []string
}{
{
name: "replace mid-line by line range preserves line boundaries",
content: "line1\nline2\nline3\nline4\n",
edit: &ASTEdit{
Operation: EditReplace,
Selector: ASTSelector{AtLine: 2},
NewContent: "replaced",
},
wantLines: []string{"line1", "replaced", "line3", "line4", ""},
},
{
name: "replace multi-line range preserves boundaries",
content: "line1\nline2\nline3\nline4\n",
edit: &ASTEdit{
Operation: EditReplace,
Selector: ASTSelector{AtLine: 2, LineEnd: 3},
NewContent: "newA\nnewB",
},
wantLines: []string{"line1", "newA", "newB", "line4", ""},
},
{
name: "replace last line without trailing newline",
content: "line1\nline2\nline3",
edit: &ASTEdit{
Operation: EditReplace,
Selector: ASTSelector{AtLine: 3},
NewContent: "replaced",
},
wantLines: []string{"line1", "line2", "replaced"},
},
{
name: "replace single line with multi-line content",
content: "before\ntarget\nafter\n",
edit: &ASTEdit{
Operation: EditReplace,
Selector: ASTSelector{AtLine: 2},
NewContent: "new1\nnew2\nnew3",
},
wantLines: []string{"before", "new1", "new2", "new3", "after", ""},
},
{
name: "replace by exact text preserves surrounding lines",
content: "aaa\nbbb\nccc\n",
edit: &ASTEdit{
Operation: EditReplace,
Selector: ASTSelector{Text: "bbb"},
NewContent: "xxx",
},
wantLines: []string{"aaa", "xxx", "ccc", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
tt.edit.File = tmpFile
result, err := e.Apply(context.Background(), tt.edit)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !result.Success {
t.Fatalf("not successful: %s", result.Error)
}
got, _ := os.ReadFile(tmpFile)
gotLines := strings.Split(string(got), "\n")
if len(gotLines) != len(tt.wantLines) {
t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines)
}
for i, want := range tt.wantLines {
if gotLines[i] != want {
t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want)
}
}
})
}
}
func TestRegressionLineMergeInsertAfter(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tests := []struct {
name string
content string
edit *ASTEdit
wantLines []string
}{
{
name: "insert after mid-line doesn't merge with next line",
content: "line1\nline2\nline3\n",
edit: &ASTEdit{
Operation: EditInsertAfter,
Selector: ASTSelector{Text: "line2"},
NewContent: "inserted",
},
wantLines: []string{"line1", "line2", "inserted", "line3", ""},
},
{
name: "insert after last line without trailing newline",
content: "line1\nline2\nline3",
edit: &ASTEdit{
Operation: EditInsertAfter,
Selector: ASTSelector{Text: "line3"},
NewContent: "inserted",
},
wantLines: []string{"line1", "line2", "line3", "inserted"},
},
{
name: "insert multi-line block after line",
content: "before\ntarget\nafter\n",
edit: &ASTEdit{
Operation: EditInsertAfter,
Selector: ASTSelector{Text: "target"},
NewContent: "new1\nnew2\nnew3",
},
wantLines: []string{"before", "target", "new1", "new2", "new3", "after", ""},
},
{
name: "insert after by line number",
content: "aaa\nbbb\nccc\n",
edit: &ASTEdit{
Operation: EditInsertAfter,
Selector: ASTSelector{AtLine: 2},
NewContent: "inserted",
},
wantLines: []string{"aaa", "bbb", "inserted", "ccc", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
tt.edit.File = tmpFile
result, err := e.Apply(context.Background(), tt.edit)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !result.Success {
t.Fatalf("not successful: %s", result.Error)
}
got, _ := os.ReadFile(tmpFile)
gotLines := strings.Split(string(got), "\n")
if len(gotLines) != len(tt.wantLines) {
t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines)
}
for i, want := range tt.wantLines {
if gotLines[i] != want {
t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want)
}
}
})
}
}
func TestRegressionLineMergeInsertBefore(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tests := []struct {
name string
content string
edit *ASTEdit
wantLines []string
}{
{
name: "insert before mid-line doesn't merge",
content: "line1\nline2\nline3\n",
edit: &ASTEdit{
Operation: EditInsertBefore,
Selector: ASTSelector{Text: "line2"},
NewContent: "inserted",
},
wantLines: []string{"line1", "inserted", "line2", "line3", ""},
},
{
name: "insert multi-line block before line",
content: "before\ntarget\nafter\n",
edit: &ASTEdit{
Operation: EditInsertBefore,
Selector: ASTSelector{Text: "target"},
NewContent: "new1\nnew2",
},
wantLines: []string{"before", "new1", "new2", "target", "after", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
tt.edit.File = tmpFile
result, err := e.Apply(context.Background(), tt.edit)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !result.Success {
t.Fatalf("not successful: %s", result.Error)
}
got, _ := os.ReadFile(tmpFile)
gotLines := strings.Split(string(got), "\n")
if len(gotLines) != len(tt.wantLines) {
t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines)
}
for i, want := range tt.wantLines {
if gotLines[i] != want {
t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want)
}
}
})
}
}
func TestRegressionLineMergeDelete(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tests := []struct {
name string
content string
edit *ASTEdit
wantLines []string
}{
{
name: "delete mid-line preserves surrounding lines",
content: "line1\nline2\nline3\n",
edit: &ASTEdit{
Operation: EditDelete,
Selector: ASTSelector{AtLine: 2},
},
wantLines: []string{"line1", "line3", ""},
},
{
name: "delete line range preserves surrounding lines",
content: "line1\nline2\nline3\nline4\n",
edit: &ASTEdit{
Operation: EditDelete,
Selector: ASTSelector{AtLine: 2, LineEnd: 3},
},
wantLines: []string{"line1", "line4", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
tt.edit.File = tmpFile
result, err := e.Apply(context.Background(), tt.edit)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !result.Success {
t.Fatalf("not successful: %s", result.Error)
}
got, _ := os.ReadFile(tmpFile)
gotLines := strings.Split(string(got), "\n")
if len(gotLines) != len(tt.wantLines) {
t.Fatalf("line count = %d, want %d\ngot: %q\nwant: %q", len(gotLines), len(tt.wantLines), gotLines, tt.wantLines)
}
for i, want := range tt.wantLines {
if gotLines[i] != want {
t.Errorf("line %d = %q, want %q", i+1, gotLines[i], want)
}
}
})
}
}
+52 -2
View File
@@ -16,6 +16,12 @@ import (
json "github.com/goccy/go-json"
)
// ProcessKillTimeout is the timeout for waiting for a process to exit before force killing.
const ProcessKillTimeout = 5 * time.Second
// StderrBufferSize is the buffer size for draining stderr.
const StderrBufferSize = 1024
// Client represents an LSP client connection.
type Client struct {
stdin io.WriteCloser
@@ -104,12 +110,33 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
notifications: make(chan *Notification, 100),
}
// Start reader goroutine
// Start reader goroutine for stdout
go c.readLoop()
// Start stderr drain goroutine to prevent pipe buffer from filling up
go c.drainStderr()
return c, nil
}
// drainStderr consumes stderr output to prevent the LSP server from blocking.
// LSP servers may write diagnostic messages to stderr which we discard.
func (c *Client) drainStderr() {
buf := make([]byte, StderrBufferSize)
for {
select {
case <-c.done:
return
default:
}
// Read and discard stderr output
_, err := c.stderr.Read(buf)
if err != nil {
return
}
}
}
// Call sends a request and waits for a response.
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
c.runningMu.RLock()
@@ -208,7 +235,7 @@ func (c *Client) Close() error {
select {
case <-done:
// Clean exit
case <-time.After(5 * time.Second):
case <-time.After(ProcessKillTimeout):
// Force kill
_ = c.cmd.Process.Kill()
}
@@ -241,7 +268,11 @@ func (c *Client) send(msg interface{}) error {
}
// readLoop reads and dispatches messages from the server.
// On exit (for any reason), it drains all pending Call waiters with a
// synthetic error so that goroutines blocked in Call are unblocked.
func (c *Client) readLoop() {
defer c.drainPending()
reader := bufio.NewReader(c.stdout)
for {
@@ -302,6 +333,25 @@ func (c *Client) readLoop() {
}
}
// drainPending sends a synthetic error response to every pending Call waiter
// so that goroutines blocked in Call are unblocked when readLoop exits.
func (c *Client) drainPending() {
c.mu.Lock()
defer c.mu.Unlock()
for id, ch := range c.pending {
ch <- &Response{
JSONRPC: "2.0",
ID: id,
Error: &ResponseError{
Code: -32603, // InternalError
Message: "LSP client readLoop terminated",
},
}
delete(c.pending, id)
}
}
// IsRunning returns whether the client is running.
func (c *Client) IsRunning() bool {
c.runningMu.RLock()
+115 -37
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -15,7 +16,22 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// LSP timeout and interval constants.
const (
// DefaultLSPTimeout is the default timeout for LSP requests.
DefaultLSPTimeout = 10 * time.Second
// DefaultIdleTimeout is the duration before idle LSP servers are reaped.
DefaultIdleTimeout = 5 * time.Minute
// ReaperInterval is how often the idle server reaper runs.
ReaperInterval = 60 * time.Second
// ShutdownTimeout is the timeout for graceful LSP server shutdown.
ShutdownTimeout = 2 * time.Second
)
// Manager manages LSP servers for different languages.
//
// Lock ordering: m.mu must always be acquired before srv.mu.
// Never acquire m.mu while holding srv.mu.
type Manager struct {
servers map[protocol.Language]*ManagedServer
logger *slog.Logger
@@ -24,6 +40,7 @@ type Manager struct {
timeout time.Duration
idleTimeout time.Duration
mu sync.RWMutex
closeOnce sync.Once
stopped bool
}
@@ -68,14 +85,32 @@ var DefaultServerConfigs = map[protocol.Language]ServerConfig{
protocol.LangCpp: {
Command: []string{"clangd"},
},
protocol.LangRust: {
Command: []string{"rust-analyzer"},
},
}
// AllowedLSPBinaries is a whitelist of allowed LSP server binary names.
// This prevents command injection by ensuring only known LSP servers can be executed.
var AllowedLSPBinaries = map[string]bool{
"gopls": true,
"typescript-language-server": true,
"pylsp": true,
"clangd": true,
// Common alternatives
"tsserver": true,
"pyright": true,
"ruff-lsp": true,
"rust-analyzer": true,
"ccls": true,
}
// NewManager creates a new LSP manager.
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
m := &Manager{
servers: make(map[protocol.Language]*ManagedServer),
timeout: 10 * time.Second,
idleTimeout: 5 * time.Minute,
timeout: DefaultLSPTimeout,
idleTimeout: DefaultIdleTimeout,
workspaceRoot: workspaceRoot,
logger: logger,
stopReaper: make(chan struct{}),
@@ -127,12 +162,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
return nil, errors.NewLSPServerNotFound(string(lang), config.Command[0])
}
// Create command
// Validate command against whitelist to prevent command injection
binaryName := filepath.Base(cmdPath)
if !AllowedLSPBinaries[binaryName] {
return nil, errors.New(errors.ErrLSPServerNotFound, fmt.Sprintf("LSP binary %q is not in the allowed list", binaryName)).
WithContext("language", string(lang)).
WithContext("binary", binaryName).
WithRemediation("Only whitelisted LSP server binaries are allowed for security reasons")
}
// Create command — use exec.Command (not CommandContext) so the LSP
// subprocess is not killed when the request-scoped context expires.
args := append(config.Command[1:], config.Args...)
cmd := exec.CommandContext(ctx, cmdPath, args...)
cmd := exec.Command(cmdPath, args...)
cmd.Env = os.Environ()
cmd.Dir = m.workspaceRoot
// Create client
client, err := NewClient(cmd)
if err != nil {
@@ -153,13 +197,20 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
openDocs: make(map[string]int),
}
// Setup cleanup on failure - ensures resources are freed if initialization fails
var initialized bool
defer func() {
if !initialized {
_ = client.Close()
// Ensure process is killed on initialization failure
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
}()
// Initialize server
if err := m.initializeServer(ctx, newSrv); err != nil {
_ = client.Close()
// Ensure process is killed on initialization failure
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
newSrv.initErr = err
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
WithContext("language", string(lang)).
@@ -167,6 +218,9 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
WithRemediation("Check LSP server logs for initialization errors")
}
// Mark as successfully initialized to prevent cleanup
initialized = true
newSrv.ready = true
m.servers[lang] = newSrv
m.logger.Info("started LSP server", "language", lang, "command", config.Command[0])
@@ -367,9 +421,13 @@ func (m *Manager) References(ctx context.Context, file string, line, col int, in
}
// ensureDocumentOpen opens a document if not already open.
func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, file string) error {
// It reads the file content outside the lock (to avoid holding the lock during I/O),
// then holds srv.mu for the entire check-and-send sequence to prevent duplicate didOpen
// notifications from concurrent goroutines.
func (m *Manager) ensureDocumentOpen(_ context.Context, srv *ManagedServer, file string) error {
uri := fileToURI(file)
// Quick check under lock — common fast path.
srv.mu.Lock()
if _, ok := srv.openDocs[uri]; ok {
srv.mu.Unlock()
@@ -377,15 +435,23 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
}
srv.mu.Unlock()
// Read file content
// Read file content outside the lock to avoid holding it during I/O.
content, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Get language ID
langID := languageToLSPID(srv.language)
// Re-acquire lock and re-check to prevent TOCTOU race: two goroutines could
// both pass the fast-path check above and both try to send didOpen.
srv.mu.Lock()
defer srv.mu.Unlock()
if _, ok := srv.openDocs[uri]; ok {
return nil
}
params := DidOpenTextDocumentParams{
TextDocument: TextDocumentItem{
URI: uri,
@@ -399,10 +465,7 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
return fmt.Errorf("didOpen failed: %w", err)
}
srv.mu.Lock()
srv.openDocs[uri] = 1
srv.mu.Unlock()
return nil
}
@@ -437,7 +500,7 @@ func (m *Manager) CloseDocument(_ context.Context, lang protocol.Language, file
// reapIdleServers periodically closes idle servers.
func (m *Manager) reapIdleServers() {
ticker := time.NewTicker(60 * time.Second)
ticker := time.NewTicker(ReaperInterval)
defer ticker.Stop()
for {
@@ -445,6 +508,10 @@ func (m *Manager) reapIdleServers() {
case <-m.stopReaper:
return
case <-ticker.C:
// Collect idle servers first to avoid holding the lock while closing
var toClose []*ManagedServer
var toCloseLanguages []protocol.Language
m.mu.Lock()
for lang, srv := range m.servers {
// Check lastUsed with server's lock to avoid race condition
@@ -453,36 +520,45 @@ func (m *Manager) reapIdleServers() {
srv.mu.Unlock()
if idle {
m.logger.Info("closing idle LSP server", "language", lang)
_ = srv.client.Close()
toClose = append(toClose, srv)
toCloseLanguages = append(toCloseLanguages, lang)
delete(m.servers, lang)
}
}
m.mu.Unlock()
// Close servers outside the lock to prevent deadlock
// (Close can block waiting for the process to exit)
for i, srv := range toClose {
m.logger.Info("closing idle LSP server", "language", toCloseLanguages[i])
_ = srv.client.Close()
}
}
}
}
// Close shuts down all LSP servers.
// Close shuts down all LSP servers. It is safe to call multiple times.
func (m *Manager) Close() error {
close(m.stopReaper)
m.closeOnce.Do(func() {
close(m.stopReaper)
m.mu.Lock()
defer m.mu.Unlock()
m.mu.Lock()
defer m.mu.Unlock()
m.stopped = true
m.stopped = true
for lang, srv := range m.servers {
m.logger.Info("shutting down LSP server", "language", lang)
// Try graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, _ = srv.client.Call(ctx, "shutdown", nil)
cancel()
_ = srv.client.Notify("exit", nil)
_ = srv.client.Close()
}
for lang, srv := range m.servers {
m.logger.Info("shutting down LSP server", "language", lang)
// Try graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
_, _ = srv.client.Call(ctx, "shutdown", nil)
cancel()
_ = srv.client.Notify("exit", nil)
_ = srv.client.Close()
}
m.servers = make(map[protocol.Language]*ManagedServer)
m.servers = make(map[protocol.Language]*ManagedServer)
})
return nil
}
@@ -497,13 +573,13 @@ func (m *Manager) IsAvailable(lang protocol.Language) bool {
return err == nil
}
// fileToURI converts a file path to a file URI.
// fileToURI converts a file path to a properly percent-encoded file URI.
func fileToURI(file string) string {
absPath, err := filepath.Abs(file)
if err != nil {
return "file://" + file
absPath = file
}
return "file://" + absPath
return (&url.URL{Scheme: "file", Path: absPath}).String()
}
// URIToFile converts a file URI to a file path.
@@ -529,6 +605,8 @@ func languageToLSPID(lang protocol.Language) string {
return "c"
case protocol.LangCpp:
return "cpp"
case protocol.LangRust:
return "rust"
default:
return string(lang)
}
+251
View File
@@ -1,7 +1,11 @@
package lsp
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
@@ -110,3 +114,250 @@ func TestDefaultServerConfigs(t *testing.T) {
}
}
}
// TestManagerTimeout tests timeout handling in LSP operations.
func TestManagerTimeout(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
// Verify timeout is set
if manager.timeout == 0 {
t.Error("manager timeout should not be zero")
}
// Verify default timeout is reasonable
if manager.timeout != 10*time.Second {
t.Errorf("expected default timeout of 10s, got %v", manager.timeout)
}
// Test that manager can handle short timeouts
manager.timeout = 1 * time.Millisecond
// Create a context that will timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// Try to get a server with very short timeout - this should fail quickly
// Use a language that doesn't have an LSP server installed
_, err := manager.GetServer(ctx, "invalid_language")
if err == nil {
t.Log("GetServer with invalid language succeeded (LSP server may be installed)")
}
}
// TestManagerConnectionFailure tests handling of LSP connection failures.
func TestManagerConnectionFailure(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
// Test 1: Invalid language
_, err := manager.GetServer(ctx, "nonexistent_language")
if err == nil {
t.Error("expected error for nonexistent language")
}
// Test 2: Try to use LSP features without a valid server
// This should fail gracefully
_, err = manager.Hover(ctx, "/tmp/test.fake", 1, 1)
if err == nil {
t.Error("expected error for hover on unsupported language")
}
}
// TestManagerGracefulShutdown tests graceful shutdown of LSP servers.
func TestManagerGracefulShutdown(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// Close should not panic even with no servers started
err := manager.Close()
if err != nil {
t.Errorf("Close() returned error: %v", err)
}
// Verify manager is stopped
if !manager.stopped {
t.Error("manager should be marked as stopped after Close()")
}
}
// TestManagerDoubleClose verifies that calling Close() twice does not panic (T-05, C-02).
func TestManagerDoubleClose(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// First close should succeed
err := manager.Close()
if err != nil {
t.Errorf("first Close() returned error: %v", err)
}
// Second close must not panic (C-02 fix wraps close in sync.Once)
err = manager.Close()
if err != nil {
t.Errorf("second Close() returned error: %v", err)
}
if !manager.stopped {
t.Error("manager should be marked as stopped after double Close()")
}
}
// TestManagerIdleReaper tests the idle server cleanup mechanism.
func TestManagerIdleReaper(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
// Set a very short idle timeout for testing
manager.idleTimeout = 100 * time.Millisecond
// Verify idle timeout is set correctly
if manager.idleTimeout != 100*time.Millisecond {
t.Errorf("expected idle timeout of 100ms, got %v", manager.idleTimeout)
}
// The reaper goroutine should be running
// We can't easily test it without actually starting LSP servers,
// but we can verify it doesn't panic on close
time.Sleep(150 * time.Millisecond)
err := manager.Close()
if err != nil {
t.Errorf("Close() with active reaper returned error: %v", err)
}
}
// TestManagerDocumentManagement tests document open/close operations.
func TestManagerDocumentManagement(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
// Test closing a document for a non-existent server
err := manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
if err != nil {
t.Errorf("CloseDocument on non-existent server should not error: %v", err)
}
// Test closing a document that was never opened
err = manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
if err != nil {
t.Errorf("CloseDocument on unopened document should not error: %v", err)
}
}
// TestManagerConcurrentAccess tests concurrent access to the manager.
func TestManagerConcurrentAccess(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
// Test concurrent IsAvailable calls
const numGoroutines = 10
done := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("panic in concurrent IsAvailable: %v", r)
}
done <- true
}()
// Call IsAvailable multiple times
for j := 0; j < 5; j++ {
_ = manager.IsAvailable(protocol.LangGo)
_ = manager.IsAvailable(protocol.LangPython)
_ = manager.IsAvailable(protocol.LangTypeScript)
}
}()
}
// Wait for all goroutines
for i := 0; i < numGoroutines; i++ {
<-done
}
}
// TestManagerErrorHandling tests various error conditions.
func TestManagerErrorHandling(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
ctx := context.Background()
tests := []struct {
name string
testFunc func() error
}{
{
name: "hover_on_nonexistent_file",
testFunc: func() error {
_, err := manager.Hover(ctx, "/nonexistent/file.go", 1, 1)
return err
},
},
{
name: "definition_on_nonexistent_file",
testFunc: func() error {
_, err := manager.Definition(ctx, "/nonexistent/file.go", 1, 1)
return err
},
},
{
name: "references_on_nonexistent_file",
testFunc: func() error {
_, err := manager.References(ctx, "/nonexistent/file.go", 1, 1, true)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.testFunc()
if err == nil {
t.Log("operation succeeded (LSP server may be handling gracefully)")
}
// We don't require an error because behavior depends on whether
// the LSP server is installed and how it handles missing files
})
}
}
// TestManagerWorkspaceRoot tests workspace root handling.
func TestManagerWorkspaceRoot(t *testing.T) {
tmpDir := t.TempDir()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
manager := NewManager(tmpDir, logger)
t.Cleanup(func() { _ = manager.Close() })
if manager.workspaceRoot != tmpDir {
t.Errorf("expected workspace root %s, got %s", tmpDir, manager.workspaceRoot)
}
}
+464
View File
@@ -0,0 +1,464 @@
// Package metrics provides Prometheus-style metrics collection for the MCP server.
// It offers low-overhead, thread-safe metric types suitable for observability.
package metrics
import (
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Counter is a monotonically increasing metric.
type Counter struct {
name string
help string
labels map[string]string
value atomic.Int64
}
// NewCounter creates a new counter metric.
func NewCounter(name, help string, labels map[string]string) *Counter {
return &Counter{
name: name,
help: help,
labels: labels,
}
}
// Inc increments the counter by 1.
func (c *Counter) Inc() {
c.value.Add(1)
}
// Add adds the given value to the counter.
func (c *Counter) Add(delta int64) {
c.value.Add(delta)
}
// Value returns the current counter value.
func (c *Counter) Value() int64 {
return c.value.Load()
}
// Reset resets the counter to 0.
func (c *Counter) Reset() {
c.value.Store(0)
}
// Gauge is a metric that can go up or down.
type Gauge struct {
name string
help string
labels map[string]string
value atomic.Int64
}
// NewGauge creates a new gauge metric.
func NewGauge(name, help string, labels map[string]string) *Gauge {
return &Gauge{
name: name,
help: help,
labels: labels,
}
}
// Set sets the gauge to the given value.
func (g *Gauge) Set(val int64) {
g.value.Store(val)
}
// Inc increments the gauge by 1.
func (g *Gauge) Inc() {
g.value.Add(1)
}
// Dec decrements the gauge by 1.
func (g *Gauge) Dec() {
g.value.Add(-1)
}
// Add adds the given value to the gauge.
func (g *Gauge) Add(delta int64) {
g.value.Add(delta)
}
// Value returns the current gauge value.
func (g *Gauge) Value() int64 {
return g.value.Load()
}
// Histogram tracks the distribution of values in predefined buckets.
type Histogram struct {
name string
help string
labels map[string]string
buckets []float64
counts []atomic.Int64
sum atomic.Int64 // sum of all observed values (in nanoseconds for durations)
count atomic.Int64 // total count of observations
}
// DefaultDurationBuckets are default buckets for request durations (in seconds).
var DefaultDurationBuckets = []float64{
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
}
// NewHistogram creates a new histogram with the given buckets.
func NewHistogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
if buckets == nil {
buckets = DefaultDurationBuckets
}
// Ensure buckets are sorted
sorted := make([]float64, len(buckets))
copy(sorted, buckets)
sort.Float64s(sorted)
h := &Histogram{
name: name,
help: help,
labels: labels,
buckets: sorted,
counts: make([]atomic.Int64, len(sorted)+1), // +1 for +Inf bucket
}
return h
}
// Observe records a value in the histogram.
func (h *Histogram) Observe(val float64) {
h.count.Add(1)
// Store sum in nanoseconds for precision with durations
h.sum.Add(int64(val * 1e9))
// Find bucket and increment
for i, bound := range h.buckets {
if val <= bound {
h.counts[i].Add(1)
return
}
}
// Value exceeds all buckets, add to +Inf
h.counts[len(h.buckets)].Add(1)
}
// ObserveDuration records a duration in seconds.
func (h *Histogram) ObserveDuration(d time.Duration) {
h.Observe(d.Seconds())
}
// Count returns the total number of observations.
func (h *Histogram) Count() int64 {
return h.count.Load()
}
// Sum returns the sum of all observations.
func (h *Histogram) Sum() float64 {
return float64(h.sum.Load()) / 1e9
}
// Registry holds all registered metrics.
type Registry struct {
mu sync.RWMutex
counters map[string]*Counter
gauges map[string]*Gauge
histograms map[string]*Histogram
}
// NewRegistry creates a new metrics registry.
func NewRegistry() *Registry {
return &Registry{
counters: make(map[string]*Counter),
gauges: make(map[string]*Gauge),
histograms: make(map[string]*Histogram),
}
}
// Counter returns or creates a counter with the given name.
func (r *Registry) Counter(name, help string, labels map[string]string) *Counter {
key := metricKey(name, labels)
r.mu.RLock()
if c, ok := r.counters[key]; ok {
r.mu.RUnlock()
return c
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
// Double-check after acquiring write lock
if c, ok := r.counters[key]; ok {
return c
}
c := NewCounter(name, help, labels)
r.counters[key] = c
return c
}
// Gauge returns or creates a gauge with the given name.
func (r *Registry) Gauge(name, help string, labels map[string]string) *Gauge {
key := metricKey(name, labels)
r.mu.RLock()
if g, ok := r.gauges[key]; ok {
r.mu.RUnlock()
return g
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if g, ok := r.gauges[key]; ok {
return g
}
g := NewGauge(name, help, labels)
r.gauges[key] = g
return g
}
// Histogram returns or creates a histogram with the given name.
func (r *Registry) Histogram(name, help string, labels map[string]string, buckets []float64) *Histogram {
key := metricKey(name, labels)
r.mu.RLock()
if h, ok := r.histograms[key]; ok {
r.mu.RUnlock()
return h
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if h, ok := r.histograms[key]; ok {
return h
}
h := NewHistogram(name, help, labels, buckets)
r.histograms[key] = h
return h
}
// metricKey creates a unique key for a metric based on name and labels.
func metricKey(name string, labels map[string]string) string {
if len(labels) == 0 {
return name
}
var parts []string
for k, v := range labels {
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
}
sort.Strings(parts)
return name + "{" + strings.Join(parts, ",") + "}"
}
// formatLabels formats labels for Prometheus output.
func formatLabels(labels map[string]string) string {
if len(labels) == 0 {
return ""
}
var parts []string
for k, v := range labels {
parts = append(parts, fmt.Sprintf("%s=%q", k, v))
}
sort.Strings(parts)
return "{" + strings.Join(parts, ",") + "}"
}
// Expose returns all metrics in Prometheus text format.
func (r *Registry) Expose() string {
r.mu.RLock()
defer r.mu.RUnlock()
var sb strings.Builder
// Export counters
for _, c := range r.counters {
if c.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", c.name, c.help))
}
sb.WriteString(fmt.Sprintf("# TYPE %s counter\n", c.name))
sb.WriteString(fmt.Sprintf("%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load()))
}
// Export gauges
for _, g := range r.gauges {
if g.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", g.name, g.help))
}
sb.WriteString(fmt.Sprintf("# TYPE %s gauge\n", g.name))
sb.WriteString(fmt.Sprintf("%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load()))
}
// Export histograms
for _, h := range r.histograms {
if h.help != "" {
sb.WriteString(fmt.Sprintf("# HELP %s %s\n", h.name, h.help))
}
sb.WriteString(fmt.Sprintf("# TYPE %s histogram\n", h.name))
// Cumulative bucket counts
var cumulative int64
for i, bound := range h.buckets {
cumulative += h.counts[i].Load()
labelStr := formatLabels(h.labels)
if labelStr == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative))
} else {
// Insert le label into existing labels
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", fmt.Sprintf(",le=\"%g\"}", bound), 1), cumulative))
}
}
// +Inf bucket
cumulative += h.counts[len(h.buckets)].Load()
labelStr := formatLabels(h.labels)
if labelStr == "" {
sb.WriteString(fmt.Sprintf("%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative))
} else {
sb.WriteString(fmt.Sprintf("%s_bucket%s %d\n", h.name,
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative))
}
// Sum and count
sb.WriteString(fmt.Sprintf("%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum()))
sb.WriteString(fmt.Sprintf("%s_count%s %d\n", h.name, formatLabels(h.labels), h.count.Load()))
}
return sb.String()
}
// Reset resets all metrics to zero.
func (r *Registry) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range r.counters {
c.Reset()
}
for _, g := range r.gauges {
g.Set(0)
}
// Note: Histograms don't have a simple reset due to atomic bucket counts
}
// ServerMetrics provides pre-defined metrics for the MCP server.
type ServerMetrics struct {
registry *Registry
// Request metrics
RequestsTotal *Counter
RequestErrors *Counter
RequestDuration *Histogram
// Cache metrics
CacheHits *Counter
CacheMisses *Counter
// LSP metrics
ActiveLSPServers *Gauge
// Parse metrics
ParseDuration *Histogram
ParseErrors *Counter
}
// NewServerMetrics creates a new set of server metrics.
func NewServerMetrics() *ServerMetrics {
r := NewRegistry()
return &ServerMetrics{
registry: r,
RequestsTotal: r.Counter(
"mcp_requests_total",
"Total number of MCP requests processed",
nil,
),
RequestErrors: r.Counter(
"mcp_request_errors_total",
"Total number of MCP request errors",
nil,
),
RequestDuration: r.Histogram(
"mcp_request_duration_seconds",
"Request duration in seconds",
nil,
DefaultDurationBuckets,
),
CacheHits: r.Counter(
"mcp_cache_hits_total",
"Total number of cache hits",
nil,
),
CacheMisses: r.Counter(
"mcp_cache_misses_total",
"Total number of cache misses",
nil,
),
ActiveLSPServers: r.Gauge(
"mcp_lsp_servers_active",
"Number of active LSP server connections",
nil,
),
ParseDuration: r.Histogram(
"mcp_parse_duration_seconds",
"Parse duration in seconds",
nil,
[]float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0},
),
ParseErrors: r.Counter(
"mcp_parse_errors_total",
"Total number of parse errors",
nil,
),
}
}
// Expose returns all metrics in Prometheus text format.
func (m *ServerMetrics) Expose() string {
return m.registry.Expose()
}
// Registry returns the underlying metrics registry.
func (m *ServerMetrics) Registry() *Registry {
return m.registry
}
// RecordRequest records a request with its duration and error status.
func (m *ServerMetrics) RecordRequest(duration time.Duration, err error) {
m.RequestsTotal.Inc()
m.RequestDuration.ObserveDuration(duration)
if err != nil {
m.RequestErrors.Inc()
}
}
// RecordParse records a parse operation with its duration and error status.
func (m *ServerMetrics) RecordParse(duration time.Duration, err error) {
m.ParseDuration.ObserveDuration(duration)
if err != nil {
m.ParseErrors.Inc()
}
}
// RecordCacheHit records a cache hit.
func (m *ServerMetrics) RecordCacheHit() {
m.CacheHits.Inc()
}
// RecordCacheMiss records a cache miss.
func (m *ServerMetrics) RecordCacheMiss() {
m.CacheMisses.Inc()
}
// SetActiveLSPServers sets the number of active LSP servers.
func (m *ServerMetrics) SetActiveLSPServers(count int64) {
m.ActiveLSPServers.Set(count)
}
+561
View File
@@ -0,0 +1,561 @@
package metrics
import (
"strings"
"sync"
"testing"
"time"
)
func TestCounter(t *testing.T) {
tests := []struct {
name string
ops func(c *Counter)
expected int64
}{
{
name: "initial value is zero",
ops: func(c *Counter) {},
expected: 0,
},
{
name: "single inc",
ops: func(c *Counter) {
c.Inc()
},
expected: 1,
},
{
name: "multiple inc",
ops: func(c *Counter) {
c.Inc()
c.Inc()
c.Inc()
},
expected: 3,
},
{
name: "add positive",
ops: func(c *Counter) {
c.Add(10)
},
expected: 10,
},
{
name: "mixed operations",
ops: func(c *Counter) {
c.Inc()
c.Add(5)
c.Inc()
},
expected: 7,
},
{
name: "reset",
ops: func(c *Counter) {
c.Add(100)
c.Reset()
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewCounter("test_counter", "test help", nil)
tt.ops(c)
if got := c.Value(); got != tt.expected {
t.Errorf("Counter.Value() = %d, want %d", got, tt.expected)
}
})
}
}
func TestCounterConcurrency(t *testing.T) {
c := NewCounter("concurrent_counter", "test", nil)
var wg sync.WaitGroup
numGoroutines := 100
incsPerGoroutine := 1000
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < incsPerGoroutine; j++ {
c.Inc()
}
}()
}
wg.Wait()
expected := int64(numGoroutines * incsPerGoroutine)
if got := c.Value(); got != expected {
t.Errorf("Counter.Value() = %d, want %d after concurrent increments", got, expected)
}
}
func TestGauge(t *testing.T) {
tests := []struct {
name string
ops func(g *Gauge)
expected int64
}{
{
name: "initial value is zero",
ops: func(g *Gauge) {},
expected: 0,
},
{
name: "set value",
ops: func(g *Gauge) {
g.Set(42)
},
expected: 42,
},
{
name: "inc",
ops: func(g *Gauge) {
g.Inc()
g.Inc()
},
expected: 2,
},
{
name: "dec",
ops: func(g *Gauge) {
g.Set(10)
g.Dec()
g.Dec()
},
expected: 8,
},
{
name: "add positive",
ops: func(g *Gauge) {
g.Add(5)
},
expected: 5,
},
{
name: "add negative",
ops: func(g *Gauge) {
g.Set(10)
g.Add(-3)
},
expected: 7,
},
{
name: "mixed operations",
ops: func(g *Gauge) {
g.Set(100)
g.Inc()
g.Dec()
g.Add(-50)
},
expected: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGauge("test_gauge", "test help", nil)
tt.ops(g)
if got := g.Value(); got != tt.expected {
t.Errorf("Gauge.Value() = %d, want %d", got, tt.expected)
}
})
}
}
func TestHistogram(t *testing.T) {
t.Run("default buckets", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, nil)
if len(h.buckets) != len(DefaultDurationBuckets) {
t.Errorf("expected default buckets, got %d buckets", len(h.buckets))
}
})
t.Run("custom buckets", func(t *testing.T) {
buckets := []float64{1.0, 5.0, 10.0}
h := NewHistogram("test_histogram", "test", nil, buckets)
if len(h.buckets) != 3 {
t.Errorf("expected 3 buckets, got %d", len(h.buckets))
}
})
t.Run("buckets are sorted", func(t *testing.T) {
buckets := []float64{10.0, 1.0, 5.0}
h := NewHistogram("test_histogram", "test", nil, buckets)
if h.buckets[0] != 1.0 || h.buckets[1] != 5.0 || h.buckets[2] != 10.0 {
t.Errorf("buckets not sorted: %v", h.buckets)
}
})
t.Run("observe values", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{1.0, 5.0, 10.0})
h.Observe(0.5) // goes to bucket 0 (<=1.0)
h.Observe(3.0) // goes to bucket 1 (<=5.0)
h.Observe(7.0) // goes to bucket 2 (<=10.0)
h.Observe(15.0) // goes to +Inf bucket
if h.Count() != 4 {
t.Errorf("expected count 4, got %d", h.Count())
}
})
t.Run("observe duration", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{0.001, 0.01, 0.1})
h.ObserveDuration(500 * time.Microsecond) // 0.0005s, goes to bucket 0
h.ObserveDuration(5 * time.Millisecond) // 0.005s, goes to bucket 1
if h.Count() != 2 {
t.Errorf("expected count 2, got %d", h.Count())
}
})
t.Run("sum tracking", func(t *testing.T) {
h := NewHistogram("test_histogram", "test", nil, []float64{1.0, 5.0, 10.0})
h.Observe(1.0)
h.Observe(2.0)
h.Observe(3.0)
expectedSum := 6.0
if got := h.Sum(); got != expectedSum {
t.Errorf("expected sum %f, got %f", expectedSum, got)
}
})
}
func TestRegistry(t *testing.T) {
t.Run("counter registration", func(t *testing.T) {
r := NewRegistry()
c1 := r.Counter("test_counter", "help", nil)
c2 := r.Counter("test_counter", "help", nil)
if c1 != c2 {
t.Error("expected same counter instance for same name")
}
})
t.Run("counter with labels", func(t *testing.T) {
r := NewRegistry()
labels1 := map[string]string{"method": "get"}
labels2 := map[string]string{"method": "post"}
c1 := r.Counter("http_requests", "help", labels1)
c2 := r.Counter("http_requests", "help", labels2)
if c1 == c2 {
t.Error("expected different counter instances for different labels")
}
})
t.Run("gauge registration", func(t *testing.T) {
r := NewRegistry()
g1 := r.Gauge("test_gauge", "help", nil)
g2 := r.Gauge("test_gauge", "help", nil)
if g1 != g2 {
t.Error("expected same gauge instance for same name")
}
})
t.Run("histogram registration", func(t *testing.T) {
r := NewRegistry()
h1 := r.Histogram("test_histogram", "help", nil, nil)
h2 := r.Histogram("test_histogram", "help", nil, nil)
if h1 != h2 {
t.Error("expected same histogram instance for same name")
}
})
}
func TestRegistryConcurrency(t *testing.T) {
r := NewRegistry()
var wg sync.WaitGroup
numGoroutines := 100
// Concurrent access to registry
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
c := r.Counter("concurrent_test", "test", nil)
c.Inc()
g := r.Gauge("concurrent_gauge", "test", nil)
g.Inc()
}(i)
}
wg.Wait()
c := r.Counter("concurrent_test", "test", nil)
if c.Value() != int64(numGoroutines) {
t.Errorf("expected counter value %d, got %d", numGoroutines, c.Value())
}
}
func TestRegistryExpose(t *testing.T) {
r := NewRegistry()
// Add some metrics
c := r.Counter("test_requests_total", "Total requests", nil)
c.Add(42)
g := r.Gauge("test_connections", "Active connections", nil)
g.Set(10)
h := r.Histogram("test_duration_seconds", "Request duration", nil, []float64{0.1, 0.5, 1.0})
h.Observe(0.05)
h.Observe(0.3)
h.Observe(0.8)
output := r.Expose()
// Check counter output
if !strings.Contains(output, "# TYPE test_requests_total counter") {
t.Error("expected counter type in output")
}
if !strings.Contains(output, "test_requests_total 42") {
t.Error("expected counter value in output")
}
// Check gauge output
if !strings.Contains(output, "# TYPE test_connections gauge") {
t.Error("expected gauge type in output")
}
if !strings.Contains(output, "test_connections 10") {
t.Error("expected gauge value in output")
}
// Check histogram output
if !strings.Contains(output, "# TYPE test_duration_seconds histogram") {
t.Error("expected histogram type in output")
}
if !strings.Contains(output, "test_duration_seconds_bucket") {
t.Error("expected histogram buckets in output")
}
if !strings.Contains(output, "test_duration_seconds_sum") {
t.Error("expected histogram sum in output")
}
if !strings.Contains(output, "test_duration_seconds_count") {
t.Error("expected histogram count in output")
}
}
func TestRegistryReset(t *testing.T) {
r := NewRegistry()
c := r.Counter("test_counter", "test", nil)
c.Add(100)
g := r.Gauge("test_gauge", "test", nil)
g.Set(50)
r.Reset()
if c.Value() != 0 {
t.Errorf("expected counter reset to 0, got %d", c.Value())
}
if g.Value() != 0 {
t.Errorf("expected gauge reset to 0, got %d", g.Value())
}
}
func TestServerMetrics(t *testing.T) {
t.Run("creation", func(t *testing.T) {
m := NewServerMetrics()
if m.RequestsTotal == nil {
t.Error("RequestsTotal should not be nil")
}
if m.RequestErrors == nil {
t.Error("RequestErrors should not be nil")
}
if m.RequestDuration == nil {
t.Error("RequestDuration should not be nil")
}
if m.CacheHits == nil {
t.Error("CacheHits should not be nil")
}
if m.CacheMisses == nil {
t.Error("CacheMisses should not be nil")
}
if m.ActiveLSPServers == nil {
t.Error("ActiveLSPServers should not be nil")
}
if m.ParseDuration == nil {
t.Error("ParseDuration should not be nil")
}
if m.ParseErrors == nil {
t.Error("ParseErrors should not be nil")
}
})
t.Run("record request success", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(100*time.Millisecond, nil)
if m.RequestsTotal.Value() != 1 {
t.Errorf("expected RequestsTotal 1, got %d", m.RequestsTotal.Value())
}
if m.RequestErrors.Value() != 0 {
t.Errorf("expected RequestErrors 0, got %d", m.RequestErrors.Value())
}
if m.RequestDuration.Count() != 1 {
t.Errorf("expected RequestDuration count 1, got %d", m.RequestDuration.Count())
}
})
t.Run("record request error", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(50*time.Millisecond, &testError{})
if m.RequestsTotal.Value() != 1 {
t.Errorf("expected RequestsTotal 1, got %d", m.RequestsTotal.Value())
}
if m.RequestErrors.Value() != 1 {
t.Errorf("expected RequestErrors 1, got %d", m.RequestErrors.Value())
}
})
t.Run("record parse", func(t *testing.T) {
m := NewServerMetrics()
m.RecordParse(10*time.Millisecond, nil)
m.RecordParse(5*time.Millisecond, &testError{})
if m.ParseDuration.Count() != 2 {
t.Errorf("expected ParseDuration count 2, got %d", m.ParseDuration.Count())
}
if m.ParseErrors.Value() != 1 {
t.Errorf("expected ParseErrors 1, got %d", m.ParseErrors.Value())
}
})
t.Run("record cache", func(t *testing.T) {
m := NewServerMetrics()
m.RecordCacheHit()
m.RecordCacheHit()
m.RecordCacheMiss()
if m.CacheHits.Value() != 2 {
t.Errorf("expected CacheHits 2, got %d", m.CacheHits.Value())
}
if m.CacheMisses.Value() != 1 {
t.Errorf("expected CacheMisses 1, got %d", m.CacheMisses.Value())
}
})
t.Run("set active LSP servers", func(t *testing.T) {
m := NewServerMetrics()
m.SetActiveLSPServers(5)
if m.ActiveLSPServers.Value() != 5 {
t.Errorf("expected ActiveLSPServers 5, got %d", m.ActiveLSPServers.Value())
}
m.SetActiveLSPServers(3)
if m.ActiveLSPServers.Value() != 3 {
t.Errorf("expected ActiveLSPServers 3, got %d", m.ActiveLSPServers.Value())
}
})
t.Run("expose", func(t *testing.T) {
m := NewServerMetrics()
m.RecordRequest(100*time.Millisecond, nil)
output := m.Expose()
if !strings.Contains(output, "mcp_requests_total") {
t.Error("expected mcp_requests_total in output")
}
if !strings.Contains(output, "mcp_request_duration_seconds") {
t.Error("expected mcp_request_duration_seconds in output")
}
})
}
func TestMetricKey(t *testing.T) {
tests := []struct {
name string
labels map[string]string
expected string
}{
{
name: "no labels",
labels: nil,
expected: "test_metric",
},
{
name: "single label",
labels: map[string]string{"method": "get"},
expected: `test_metric{method="get"}`,
},
{
name: "multiple labels sorted",
labels: map[string]string{"method": "get", "code": "200"},
expected: `test_metric{code="200",method="get"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := metricKey("test_metric", tt.labels)
if got != tt.expected {
t.Errorf("metricKey() = %q, want %q", got, tt.expected)
}
})
}
}
func TestFormatLabels(t *testing.T) {
tests := []struct {
name string
labels map[string]string
expected string
}{
{
name: "no labels",
labels: nil,
expected: "",
},
{
name: "single label",
labels: map[string]string{"method": "get"},
expected: `{method="get"}`,
},
{
name: "multiple labels sorted",
labels: map[string]string{"method": "get", "code": "200"},
expected: `{code="200",method="get"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatLabels(tt.labels)
if got != tt.expected {
t.Errorf("formatLabels() = %q, want %q", got, tt.expected)
}
})
}
}
// testError is a simple error type for testing
type testError struct{}
func (e *testError) Error() string { return "test error" }
+8 -6
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"testing"
"github.com/cespare/xxhash/v2"
)
// TestLRUCacheEviction tests that the LRU cache properly evicts old entries.
@@ -82,8 +84,8 @@ func TestContentHashCollisionResistance(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
hash1 := contentHash(tc.content1)
hash2 := contentHash(tc.content2)
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content1))
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content2))
if hash1 == hash2 {
t.Errorf("Hash collision: %s == %s for different content", hash1, hash2)
@@ -96,9 +98,9 @@ func TestContentHashCollisionResistance(t *testing.T) {
func TestContentHashConsistency(t *testing.T) {
content := []byte("package main\n\nfunc test() {}\n")
hash1 := contentHash(content)
hash2 := contentHash(content)
hash3 := contentHash(content)
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(content))
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(content))
hash3 := fmt.Sprintf("%016x", xxhash.Sum64(content))
if hash1 != hash2 || hash2 != hash3 {
t.Errorf("Hash inconsistency: %s, %s, %s", hash1, hash2, hash3)
@@ -115,7 +117,7 @@ func BenchmarkContentHash_xxHash(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = contentHash(content)
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
}
}
+168
View File
@@ -46,6 +46,10 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
return extractPythonDocComment(n, content)
case protocol.LangC, protocol.LangCpp:
return extractCDocComment(n, content)
case protocol.LangElixir:
return extractElixirDocComment(n, content)
case protocol.LangRust:
return extractRustDocComment(n, content)
default:
return nil
}
@@ -548,3 +552,167 @@ func cleanPythonDocstring(doc string) string {
return strings.TrimSpace(doc)
}
// extractRustDocComment extracts Rust documentation comments (/// style).
func extractRustDocComment(n *sitter.Node, content []byte) *DocComment {
comments := collectPrecedingComments(n, content, []string{"line_comment"})
if len(comments) == 0 {
return nil
}
// Filter for /// doc comments only
var docComments []*sitter.Node
for _, c := range comments {
text := GetNodeText(c, content)
trimmed := strings.TrimSpace(text)
if strings.HasPrefix(trimmed, "///") {
docComments = append(docComments, c)
}
}
if len(docComments) == 0 {
return nil
}
var parts []string
var raw []string
startLine := -1
endLine := -1
for _, c := range docComments {
text := GetNodeText(c, content)
raw = append(raw, text)
if startLine == -1 {
startLine = int(c.StartPoint().Row) + 1
}
endLine = int(c.EndPoint().Row) + 1
// Clean /// prefix
cleaned := strings.TrimSpace(text)
cleaned = strings.TrimPrefix(cleaned, "///")
if len(cleaned) > 0 && cleaned[0] == ' ' {
cleaned = cleaned[1:]
}
parts = append(parts, cleaned)
}
if len(parts) == 0 {
return nil
}
return &DocComment{
Text: strings.Join(parts, "\n"),
Raw: strings.Join(raw, "\n"),
Style: CommentStyleDoxygen,
Tags: nil,
StartLine: startLine,
EndLine: endLine,
}
}
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
// Elixir uses module attributes like @doc and @moduledoc for documentation.
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
// Look for @doc or @moduledoc attribute preceding this node
prev := n.PrevSibling()
for prev != nil {
// Check if this is an unary_operator with @ (module attribute)
if prev.Type() == "unary_operator" {
text := GetNodeText(prev, content)
trimmed := strings.TrimSpace(text)
// Check for @doc or @moduledoc
if strings.HasPrefix(trimmed, "@doc") || strings.HasPrefix(trimmed, "@moduledoc") {
// Extract the documentation string
docText := extractElixirDocString(prev, content)
if docText != "" {
return &DocComment{
Text: docText,
Raw: text,
Style: CommentStyleDocstring,
Tags: nil,
StartLine: int(prev.StartPoint().Row) + 1,
EndLine: int(prev.EndPoint().Row) + 1,
}
}
}
}
// Also check for regular # comments
if prev.Type() == "comment" {
comments := collectPrecedingComments(n, content, []string{"comment"})
if len(comments) > 0 {
var parts []string
var raw []string
startLine := -1
endLine := -1
for _, c := range comments {
text := GetNodeText(c, content)
raw = append(raw, text)
if startLine == -1 {
startLine = int(c.StartPoint().Row) + 1
}
endLine = int(c.EndPoint().Row) + 1
// Clean # comment
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "#"))
if cleaned != "" {
parts = append(parts, cleaned)
}
}
if len(parts) > 0 {
return &DocComment{
Text: strings.Join(parts, "\n"),
Raw: strings.Join(raw, "\n"),
Style: CommentStyleHash,
Tags: nil,
StartLine: startLine,
EndLine: endLine,
}
}
}
break
}
prev = prev.PrevSibling()
}
return nil
}
// extractElixirDocString extracts the documentation string from an Elixir @doc/@moduledoc attribute.
func extractElixirDocString(n *sitter.Node, content []byte) string {
// The doc attribute typically looks like:
// @doc """
// Documentation here
// """
// or
// @doc "Single line doc"
text := GetNodeText(n, content)
// Find the string content after @doc or @moduledoc
var docContent string
// Check for heredoc style (triple quotes)
if idx := strings.Index(text, `"""`); idx != -1 {
// Find the closing triple quotes
rest := text[idx+3:]
if endIdx := strings.Index(rest, `"""`); endIdx != -1 {
docContent = rest[:endIdx]
}
} else if idx := strings.Index(text, `"`); idx != -1 {
// Single quoted string
rest := text[idx+1:]
if endIdx := strings.Index(rest, `"`); endIdx != -1 {
docContent = rest[:endIdx]
}
}
return strings.TrimSpace(docContent)
}
+4
View File
@@ -104,6 +104,7 @@ func NoComment() {}
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -192,6 +193,7 @@ function validate(name) {}
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -291,6 +293,7 @@ func TestExtractPythonDocComment(t *testing.T) {
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
@@ -372,6 +375,7 @@ int simple() { return 1; }
if doc == nil {
t.Fatal("expected doc, got nil")
return
}
if doc.Text != tt.wantText {
+77 -24
View File
@@ -5,30 +5,46 @@ import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/cespare/xxhash/v2"
lru "github.com/hashicorp/golang-lru/v2"
sitter "github.com/smacker/go-tree-sitter"
"github.com/smacker/go-tree-sitter/c"
"github.com/smacker/go-tree-sitter/cpp"
"github.com/smacker/go-tree-sitter/elixir"
"github.com/smacker/go-tree-sitter/golang"
"github.com/smacker/go-tree-sitter/html"
"github.com/smacker/go-tree-sitter/javascript"
"github.com/smacker/go-tree-sitter/python"
"github.com/smacker/go-tree-sitter/rust"
"github.com/smacker/go-tree-sitter/typescript/typescript"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// MaxFileSize is the maximum file size we'll parse (10MB).
const MaxFileSize = 10 * 1024 * 1024
// maxFileSize is the default maximum file size we'll parse (10MB).
const maxFileSize = 10 * 1024 * 1024
// Registry manages Tree-sitter parsers for different languages.
type Registry struct {
parsers map[protocol.Language]*sitter.Parser
cache *lru.Cache[string, *CachedTree]
mu sync.RWMutex
parsers map[protocol.Language]*sitter.Parser
cache *lru.Cache[string, *CachedTree]
maxParseSize int64
mu sync.RWMutex
parserMu sync.Map // per-language mutexes for parse serialization
closed bool // Indicates if the registry has been closed
// Cache metrics (atomic for thread-safety)
cacheHits atomic.Int64
cacheMisses atomic.Int64
// Parse duration tracking
totalParseTime atomic.Int64 // nanoseconds
parseCount atomic.Int64
lastParseDuration atomic.Int64 // nanoseconds
}
// CachedTree stores a parsed tree with its metadata.
@@ -53,8 +69,15 @@ type SyntaxError struct {
Location protocol.Location
}
// NewRegistry creates a new parser registry.
// NewRegistry creates a new parser registry with the default max parse size.
// For custom max parse size, use NewRegistryWithSize.
func NewRegistry() *Registry {
return NewRegistryWithSize(0)
}
// NewRegistryWithSize creates a new parser registry with the specified max parse size.
// If maxParseSize is 0 or negative, uses the default MaxFileSize constant.
func NewRegistryWithSize(maxParseSize int64) *Registry {
// Create LRU cache with capacity of 100 trees
cache, err := lru.New[string, *CachedTree](100)
if err != nil {
@@ -62,9 +85,14 @@ func NewRegistry() *Registry {
panic(fmt.Sprintf("failed to create LRU cache: %v", err))
}
if maxParseSize <= 0 {
maxParseSize = maxFileSize
}
return &Registry{
parsers: make(map[protocol.Language]*sitter.Parser),
cache: cache,
parsers: make(map[protocol.Language]*sitter.Parser),
cache: cache,
maxParseSize: maxParseSize,
}
}
@@ -88,16 +116,25 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
case protocol.LangVue:
// Vue SFC files use HTML-like template syntax, so we use the HTML parser
return html.GetLanguage(), nil
case protocol.LangElixir:
return elixir.GetLanguage(), nil
case protocol.LangRust:
return rust.GetLanguage(), nil
default:
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
WithContext("language", string(lang)).
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue")
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, Rust")
}
}
// GetParser returns a parser for the given language.
// Returns an error if the registry has been closed.
func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
r.mu.RLock()
if r.closed {
r.mu.RUnlock()
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
}
if p, ok := r.parsers[lang]; ok {
r.mu.RUnlock()
return p, nil
@@ -108,6 +145,11 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
r.mu.Lock()
defer r.mu.Unlock()
// Check closed again after acquiring write lock
if r.closed {
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
}
// Double-check after acquiring write lock
if p, ok := r.parsers[lang]; ok {
return p, nil
@@ -127,9 +169,9 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
// Parse parses the given content for the specified language.
func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
// Check file size against configured limit
if int64(len(content)) > r.maxParseSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), r.maxParseSize)
}
// Detect binary files
@@ -156,8 +198,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
}
// Check cache (LRU cache is thread-safe)
hash := contentHash(content)
// Include language in cache key to prevent cross-language collisions
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
r.cacheHits.Add(1)
errors := extractErrors(cached.Tree.RootNode(), content)
return &ParseResult{
Tree: cached.Tree,
@@ -166,6 +210,7 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
Content: content,
}, nil
}
r.cacheMisses.Add(1)
// Get parser
parser, err := r.GetParser(lang)
@@ -173,11 +218,22 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
return nil, err
}
// Parse content - tree-sitter parsers are not thread-safe,
// so we need to hold the lock during parsing
r.mu.Lock()
// Parse content - tree-sitter parsers are not thread-safe per instance,
// but parsers for different languages are independent.
// Use per-language locks to allow concurrent parsing of different languages.
muVal, _ := r.parserMu.LoadOrStore(lang, &sync.Mutex{})
langMu := muVal.(*sync.Mutex)
start := time.Now()
langMu.Lock()
tree, err := parser.ParseCtx(ctx, nil, content)
r.mu.Unlock()
langMu.Unlock()
duration := time.Since(start)
// Update duration metrics
r.totalParseTime.Add(duration.Nanoseconds())
r.parseCount.Add(1)
r.lastParseDuration.Store(duration.Nanoseconds())
if err != nil {
return nil, errors.NewParseError(string(lang), filename, err)
@@ -236,13 +292,6 @@ func extractErrors(node *sitter.Node, _ []byte) []SyntaxError {
return errors
}
// contentHash returns a fast hash of the content for caching.
// Uses xxHash which is 5-10x faster than SHA256 for non-cryptographic purposes.
func contentHash(content []byte) string {
h := xxhash.Sum64(content)
return fmt.Sprintf("%016x", h)
}
// isBinary checks if content appears to be binary.
func isBinary(content []byte) bool {
// Check first 8000 bytes for null bytes
@@ -257,10 +306,14 @@ func isBinary(content []byte) bool {
}
// Close closes all parsers and clears the cache.
// After Close is called, the registry cannot be used for parsing.
func (r *Registry) Close() {
r.mu.Lock()
defer r.mu.Unlock()
// Mark as closed first to prevent new parse operations
r.closed = true
for _, p := range r.parsers {
p.Close()
}
+462
View File
@@ -0,0 +1,462 @@
package parser
import (
"context"
"fmt"
"strings"
"testing"
"github.com/cespare/xxhash/v2"
)
// BenchmarkParse benchmarks parsing files of various sizes.
func BenchmarkParse(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
benchmarks := []struct {
name string
content string
}{
{
name: "small_file_100_lines",
content: generateGoCode(100),
},
{
name: "medium_file_1000_lines",
content: generateGoCode(1000),
},
{
name: "large_file_5000_lines",
content: generateGoCode(5000),
},
{
name: "very_large_file_10000_lines",
content: generateGoCode(10000),
},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
content := []byte(bm.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkParseCacheHit benchmarks cache hit performance.
func BenchmarkParseCacheHit(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
content := []byte(generateGoCode(1000))
// Warm up the cache
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("initial parse failed: %v", err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
}
// BenchmarkParseCacheMiss benchmarks cache miss performance.
func BenchmarkParseCacheMiss(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Use different content each time to force cache miss
content := []byte(generateGoCodeWithSuffix(1000, i))
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
}
// BenchmarkParseLanguages benchmarks parsing different language files.
func BenchmarkParseLanguages(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
languages := []struct {
name string
filename string
content string
}{
{
name: "go",
filename: "test.go",
content: generateGoCode(500),
},
{
name: "typescript",
filename: "test.ts",
content: generateTypeScriptCode(500),
},
{
name: "python",
filename: "test.py",
content: generatePythonCode(500),
},
{
name: "javascript",
filename: "test.js",
content: generateJavaScriptCode(500),
},
}
for _, lang := range languages {
b.Run(lang.name, func(b *testing.B) {
content := []byte(lang.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, lang.filename, content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkParseComplexity benchmarks parsing files with varying complexity.
func BenchmarkParseComplexity(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
benchmarks := []struct {
name string
content string
}{
{
name: "simple_functions",
content: generateSimpleFunctions(100),
},
{
name: "nested_structures",
content: generateNestedStructures(50),
},
{
name: "complex_types",
content: generateComplexTypes(50),
},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
content := []byte(bm.content)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkContentHash benchmarks the content hashing function.
func BenchmarkContentHash(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(formatSize(size), func(b *testing.B) {
content := []byte(strings.Repeat("a", size))
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
}
})
}
}
// BenchmarkIsBinary benchmarks the binary detection function.
func BenchmarkIsBinary(b *testing.B) {
sizes := []int{100, 1000, 8000, 10000}
for _, size := range sizes {
b.Run(formatSize(size)+"_text", func(b *testing.B) {
content := []byte(strings.Repeat("Hello, World!\n", size/14))
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isBinary(content)
}
})
b.Run(formatSize(size)+"_binary", func(b *testing.B) {
content := make([]byte, size)
for j := 0; j < size; j++ {
content[j] = byte(j % 256)
}
content[size/2] = 0 // Add null byte
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isBinary(content)
}
})
}
}
// BenchmarkParseWithMaxSize benchmarks parsing with different max size limits.
func BenchmarkParseWithMaxSize(b *testing.B) {
ctx := context.Background()
limits := []int64{
10 * 1024, // 10KB
100 * 1024, // 100KB
1024 * 1024, // 1MB
10 * 1024 * 1024, // 10MB
}
content := []byte(generateGoCode(500))
for _, limit := range limits {
b.Run(formatBytes(limit), func(b *testing.B) {
// Skip if content is larger than limit
if int64(len(content)) > limit {
b.Skipf("content size %d exceeds limit %d", len(content), limit)
}
registry := NewRegistryWithSize(limit)
defer registry.Close()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
}
// BenchmarkConcurrentParse benchmarks concurrent parsing operations.
func BenchmarkConcurrentParse(b *testing.B) {
registry := NewRegistry()
defer registry.Close()
ctx := context.Background()
content := []byte(generateGoCode(500))
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, err := registry.Parse(ctx, "test.go", content)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
})
}
// Helper functions to generate test code
func generateGoCode(lines int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < lines/10; i++ {
sb.WriteString("func Function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b int) int {\n")
sb.WriteString("\tif a > b {\n")
sb.WriteString("\t\treturn a + b\n")
sb.WriteString("\t}\n")
sb.WriteString("\treturn a - b\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateGoCodeWithSuffix(lines int, suffix int) string {
code := generateGoCode(lines)
return code + "// Suffix: " + itoa(suffix) + "\n"
}
func generateTypeScriptCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/8; i++ {
sb.WriteString("function function")
sb.WriteString(itoa(i))
sb.WriteString("(a: number, b: number): number {\n")
sb.WriteString(" if (a > b) {\n")
sb.WriteString(" return a + b;\n")
sb.WriteString(" }\n")
sb.WriteString(" return a - b;\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generatePythonCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/6; i++ {
sb.WriteString("def function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b):\n")
sb.WriteString(" if a > b:\n")
sb.WriteString(" return a + b\n")
sb.WriteString(" return a - b\n\n")
}
return sb.String()
}
func generateJavaScriptCode(lines int) string {
var sb strings.Builder
for i := 0; i < lines/8; i++ {
sb.WriteString("function function")
sb.WriteString(itoa(i))
sb.WriteString("(a, b) {\n")
sb.WriteString(" if (a > b) {\n")
sb.WriteString(" return a + b;\n")
sb.WriteString(" }\n")
sb.WriteString(" return a - b;\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateSimpleFunctions(count int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < count; i++ {
sb.WriteString("func Func")
sb.WriteString(itoa(i))
sb.WriteString("() { }\n\n")
}
return sb.String()
}
func generateNestedStructures(depth int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < depth; i++ {
sb.WriteString("type Struct")
sb.WriteString(itoa(i))
sb.WriteString(" struct {\n")
sb.WriteString("\tField1 int\n")
sb.WriteString("\tField2 string\n")
if i > 0 {
sb.WriteString("\tNested Struct")
sb.WriteString(itoa(i - 1))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
}
return sb.String()
}
func generateComplexTypes(count int) string {
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < count; i++ {
sb.WriteString("type Type")
sb.WriteString(itoa(i))
sb.WriteString(" interface {\n")
sb.WriteString("\tMethod1() error\n")
sb.WriteString("\tMethod2(a int, b string) (int, error)\n")
sb.WriteString("\tMethod3() chan interface{}\n")
sb.WriteString("}\n\n")
}
return sb.String()
}
func formatSize(size int) string {
if size < 1000 {
return itoa(size) + "B"
}
return itoa(size/1000) + "KB"
}
func formatBytes(bytes int64) string {
if bytes < 1024 {
return itoa(int(bytes)) + "B"
}
if bytes < 1024*1024 {
return itoa(int(bytes/1024)) + "KB"
}
return itoa(int(bytes/(1024*1024))) + "MB"
}
// Simple integer to string conversion without importing strconv
func itoa(n int) string {
if n == 0 {
return "0"
}
negative := n < 0
if negative {
n = -n
}
var buf [20]byte
i := len(buf) - 1
for n > 0 {
buf[i] = byte('0' + n%10)
n /= 10
i--
}
if negative {
buf[i] = '-'
i--
}
return string(buf[i+1:])
}
+345
View File
@@ -0,0 +1,345 @@
// Package parser provides skeleton rendering for source files.
package parser
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// SkeletonFile returns a skeleton representation of the file: top-level declarations
// with signatures and doc-comments intact, but function/method bodies replaced with
// a language-appropriate placeholder.
//
// Supported: go, typescript, javascript, python, rust.
// Other languages fall back to symbols_only (AST summary text).
// Returns (skeletonText, isFullSkeleton, error).
func SkeletonFile(ctx context.Context, reg *Registry, filename string, content []byte) (string, bool, error) {
result, err := reg.Parse(ctx, filename, content)
if err != nil {
return "", false, err
}
lang := protocol.DetectLanguage(filename)
switch lang {
case protocol.LangGo:
return skeletonGo(result.Tree, content), true, nil
case protocol.LangTypeScript, protocol.LangJavaScript:
return skeletonTS(result.Tree, content), true, nil
case protocol.LangPython:
return skeletonPython(result.Tree, content), true, nil
case protocol.LangRust:
return skeletonRust(result.Tree, content), true, nil
default:
// TODO: skeleton for c, cpp, elixir, html, vue — fall back to symbols_only
syms := ExtractSymbols(result.Tree, content, lang, filename)
return renderSymbolsOnly(syms, filename, lang, content), false, nil
}
}
// renderSymbolsOnly renders a simple symbol list (fallback for unsupported languages).
func renderSymbolsOnly(syms []protocol.Symbol, _ string, lang protocol.Language, content []byte) string {
lines := strings.Split(string(content), "\n")
var sb strings.Builder
sb.WriteString(fmt.Sprintf("// skeleton unavailable for %s — symbol list only\n", lang))
for _, s := range syms {
sb.WriteString(fmt.Sprintf("// %s %s (line %d)\n", s.Kind, s.Name, s.Location.Line))
if s.Doc != "" {
sb.WriteString(fmt.Sprintf("// doc: %s\n", s.Doc))
}
if s.Location.Line >= 1 && s.Location.Line <= len(lines) {
sb.WriteString(lines[s.Location.Line-1] + " { ... }\n")
}
}
return sb.String()
}
// ---- Go skeleton ----
// skeletonGoBodyNodes lists Go node types that have a body field to replace.
var skeletonGoBodyNodes = map[string]string{
"function_declaration": "body",
"method_declaration": "body",
}
func skeletonGo(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonGoNode(root, content, &sb)
return sb.String()
}
func skeletonGoNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
if bodyField, ok := skeletonGoBodyNodes[nodeType]; ok {
body := node.ChildByFieldName(bodyField)
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
switch nodeType {
case "source_file":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonGoNode(child, content, sb)
}
return
case "comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
default:
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
}
// ---- TypeScript / JavaScript skeleton ----
func skeletonTS(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonTSNode(root, content, &sb)
return sb.String()
}
func skeletonTSNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "program":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonTSNode(child, content, sb)
}
return
case "function_declaration":
body := node.ChildByFieldName("body")
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
case "class_declaration":
body := node.ChildByFieldName("body")
if body != nil {
header := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(header)
sb.WriteString("{\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "method_definition" {
methBody := child.ChildByFieldName("body")
if methBody != nil {
methSig := strings.TrimRight(string(content[child.StartByte():methBody.StartByte()]), " \t")
sb.WriteString(" ")
sb.WriteString(methSig)
sb.WriteString("{ ... }\n")
continue
}
}
sb.WriteString(" ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
return
}
case "comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
// ---- Python skeleton ----
func skeletonPython(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonPythonNode(root, content, &sb, "")
return sb.String()
}
func skeletonPythonNode(node *sitter.Node, content []byte, sb *strings.Builder, indent string) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "module":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonPythonNode(child, content, sb, indent)
}
return
case "function_definition", "decorated_definition":
nodeText := string(content[node.StartByte():node.EndByte()])
lines := strings.SplitN(nodeText, "\n", 2)
sb.WriteString(indent)
sb.WriteString(lines[0])
sb.WriteString("\n")
sb.WriteString(indent)
sb.WriteString(" ...\n\n")
return
case "class_definition":
body := node.ChildByFieldName("body")
if body != nil {
header := string(content[node.StartByte():body.StartByte()])
firstLine := strings.SplitN(header, "\n", 2)[0]
sb.WriteString(indent)
sb.WriteString(firstLine)
sb.WriteString("\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "function_definition" || child.Type() == "decorated_definition" {
childText := string(content[child.StartByte():child.EndByte()])
childLines := strings.SplitN(childText, "\n", 2)
sb.WriteString(indent + " ")
sb.WriteString(childLines[0])
sb.WriteString("\n")
sb.WriteString(indent + " ...")
sb.WriteString("\n")
continue
}
if child.Type() == "expression_statement" {
sb.WriteString(indent + " ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
continue
}
}
sb.WriteString("\n")
return
}
case "comment":
sb.WriteString(indent)
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(indent)
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
// ---- Rust skeleton ----
func skeletonRust(tree *sitter.Tree, content []byte) string {
if tree == nil {
return string(content)
}
root := tree.RootNode()
var sb strings.Builder
skeletonRustNode(root, content, &sb)
return sb.String()
}
func skeletonRustNode(node *sitter.Node, content []byte, sb *strings.Builder) {
if node == nil {
return
}
nodeType := node.Type()
switch nodeType {
case "source_file":
for i := 0; i < int(node.ChildCount()); i++ {
child := node.Child(i)
if child == nil {
continue
}
skeletonRustNode(child, content, sb)
}
return
case "function_item":
body := node.ChildByFieldName("body")
if body != nil {
sig := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(sig)
sb.WriteString("{ ... }\n\n")
return
}
case "impl_item":
body := node.ChildByFieldName("body")
if body != nil {
header := strings.TrimRight(string(content[node.StartByte():body.StartByte()]), " \t")
sb.WriteString(header)
sb.WriteString("{\n")
for i := 0; i < int(body.ChildCount()); i++ {
child := body.Child(i)
if child == nil {
continue
}
if child.Type() == "function_item" {
methBody := child.ChildByFieldName("body")
if methBody != nil {
methSig := strings.TrimRight(string(content[child.StartByte():methBody.StartByte()]), " \t")
sb.WriteString(" ")
sb.WriteString(methSig)
sb.WriteString("{ ... }\n")
continue
}
}
sb.WriteString(" ")
sb.WriteString(string(content[child.StartByte():child.EndByte()]))
sb.WriteString("\n")
}
sb.WriteString("}\n\n")
return
}
case "line_comment", "block_comment":
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
return
}
sb.WriteString(string(content[node.StartByte():node.EndByte()]))
sb.WriteString("\n")
}
+299
View File
@@ -0,0 +1,299 @@
package parser
import (
"strings"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// StripFlag names the categories of content to remove.
type StripFlag string
const (
StripImports StripFlag = "imports"
StripLicense StripFlag = "license"
StripBlockComments StripFlag = "block_comments"
)
// StripResult holds the stripped content and which flags actually removed content.
type StripResult struct {
Content string
Stripped []StripFlag
}
// StripContent applies requested strip operations to content, in order:
// license → imports → block_comments.
// lang is used to pick language-specific heuristics.
func StripContent(content string, flags []StripFlag, lang protocol.Language) StripResult {
flagSet := make(map[StripFlag]bool, len(flags))
for _, f := range flags {
flagSet[f] = true
}
var stripped []StripFlag
if flagSet[StripLicense] {
next, removed := stripLicense(content)
if removed {
content = next
stripped = append(stripped, StripLicense)
}
}
if flagSet[StripImports] {
next, removed := stripImports(content, lang)
if removed {
content = next
stripped = append(stripped, StripImports)
}
}
if flagSet[StripBlockComments] {
next, removed := stripBlockComments(content, lang)
if removed {
content = next
stripped = append(stripped, StripBlockComments)
}
}
return StripResult{Content: content, Stripped: stripped}
}
// stripLicense removes a leading block comment that looks like a license header.
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive).
func stripLicense(content string) (string, bool) {
trimmed := strings.TrimLeft(content, " \t\n\r")
// C-style block comment at top
if strings.HasPrefix(trimmed, "/*") {
end := strings.Index(trimmed, "*/")
if end >= 0 {
candidate := trimmed[:end+2]
lower := strings.ToLower(candidate)
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
rest := trimmed[end+2:]
// Consume trailing newline(s)
rest = strings.TrimLeft(rest, "\r\n")
return rest, true
}
}
}
// Python/hash-style leading comment block
if strings.HasPrefix(trimmed, "#") {
lines := strings.Split(trimmed, "\n")
var commentLines []string
var rest []string
inComment := true
for i, l := range lines {
if inComment && (strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "") {
commentLines = append(commentLines, l)
} else {
rest = lines[i:]
break
}
}
block := strings.Join(commentLines, "\n")
lower := strings.ToLower(block)
if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") {
return strings.Join(rest, "\n"), true
}
}
return content, false
}
// stripImports removes top-of-file import blocks, language-specific.
func stripImports(content string, lang protocol.Language) (string, bool) {
switch lang {
case protocol.LangGo:
return stripGoImports(content)
case protocol.LangTypeScript, protocol.LangJavaScript:
return stripTSImports(content)
case protocol.LangPython:
return stripPythonImports(content)
case protocol.LangRust:
return stripRustImports(content)
default:
return content, false
}
}
// stripGoImports removes Go import(...) or single import "..." declarations.
func stripGoImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
i := 0
for i < len(lines) {
trimLine := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimLine, "import (") || trimLine == "import (" {
// multi-line import block
removed = true
i++ // skip "import ("
for i < len(lines) {
if strings.TrimSpace(lines[i]) == ")" {
i++ // skip closing ")"
break
}
i++
}
// skip one blank line after
if i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
continue
}
if strings.HasPrefix(trimLine, `import "`) || strings.HasPrefix(trimLine, "import `") {
removed = true
i++
continue
}
out = append(out, lines[i])
i++
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripTSImports removes TypeScript/JavaScript "import ... from ..." and "require(...)" lines.
func stripTSImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") {
removed = true
continue
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripPythonImports removes Python "import ..." and "from ... import ..." lines.
func stripPythonImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "from ") {
removed = true
continue
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripRustImports removes Rust "use ..." declarations.
func stripRustImports(content string) (string, bool) {
lines := strings.Split(content, "\n")
var out []string
removed := false
inMulti := false
for _, l := range lines {
trimLine := strings.TrimSpace(l)
if inMulti {
// look for semicolon terminating multi-line use
if strings.Contains(trimLine, ";") {
inMulti = false
}
removed = true
continue
}
if strings.HasPrefix(trimLine, "use ") {
removed = true
if !strings.HasSuffix(trimLine, ";") {
inMulti = true
}
continue
}
out = append(out, l)
}
if !removed {
return content, false
}
return strings.Join(out, "\n"), true
}
// stripBlockComments removes /* ... */ block comments (Go/TS/C/Rust)
// and Python triple-quoted docstrings.
func stripBlockComments(content string, lang protocol.Language) (string, bool) {
if lang == protocol.LangPython {
return stripPythonDocstrings(content)
}
return stripCStyleBlockComments(content)
}
// stripCStyleBlockComments removes /* ... */ from content.
func stripCStyleBlockComments(content string) (string, bool) {
removed := false
var sb strings.Builder
i := 0
for i < len(content) {
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
// find closing */
end := strings.Index(content[i+2:], "*/")
if end >= 0 {
removed = true
// advance past */
i = i + 2 + end + 2
// consume trailing newline
if i < len(content) && content[i] == '\n' {
i++
}
continue
}
}
sb.WriteByte(content[i])
i++
}
if !removed {
return content, false
}
return sb.String(), true
}
// stripPythonDocstrings removes triple-quoted strings (""" and ”').
func stripPythonDocstrings(content string) (string, bool) {
removed := false
var sb strings.Builder
i := 0
for i < len(content) {
if i+2 < len(content) {
triple := content[i : i+3]
if triple == `"""` || triple == `'''` {
end := strings.Index(content[i+3:], triple)
if end >= 0 {
removed = true
i = i + 3 + end + 3
if i < len(content) && content[i] == '\n' {
i++
}
continue
}
}
}
sb.WriteByte(content[i])
i++
}
if !removed {
return content, false
}
return sb.String(), true
}
+475
View File
@@ -25,6 +25,10 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
return extractPythonSymbols(root, content, filename)
case protocol.LangC, protocol.LangCpp:
return extractCSymbols(root, content, filename)
case protocol.LangElixir:
return extractElixirSymbols(root, content, filename)
case protocol.LangRust:
return extractRustSymbols(root, content, filename)
default:
return nil
}
@@ -472,3 +476,474 @@ func hasFunctionDeclarator(n *sitter.Node) bool {
})
return found
}
// extractElixirSymbols extracts symbols from Elixir code.
// Elixir uses `defmodule` for modules, `def`/`defp` for functions, and `defmacro`/`defmacrop` for macros.
func extractElixirSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol
WalkTree(root, func(n *sitter.Node) bool {
var symbol *protocol.Symbol
switch n.Type() {
case "call":
symbol = extractElixirCall(n, content, filename)
}
if symbol != nil {
if doc := ExtractDocComment(n, content, protocol.LangElixir); doc != nil {
symbol.Doc = FormatDocComment(doc)
}
symbols = append(symbols, *symbol)
}
return true
})
return symbols
}
// extractElixirCall extracts symbols from Elixir call nodes (def, defp, defmodule, defmacro, etc.).
func extractElixirCall(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Get the function being called (first child is usually the target)
if n.NamedChildCount() < 1 {
return nil
}
target := n.NamedChild(0)
if target == nil {
return nil
}
targetText := GetNodeText(target, content)
switch targetText {
case "defmodule":
return extractElixirModule(n, content, filename)
case "def", "defp":
return extractElixirFunction(n, content, filename, targetText == "defp")
case "defmacro", "defmacrop":
return extractElixirMacro(n, content, filename)
case "defstruct":
return extractElixirStruct(n, content, filename)
case "defprotocol":
return extractElixirProtocol(n, content, filename)
case "defimpl":
return extractElixirImpl(n, content, filename)
}
return nil
}
// extractElixirModule extracts a module definition.
func extractElixirModule(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defmodule ModuleName do ... end
// The module name is in the arguments
args := n.ChildByFieldName("arguments")
if args == nil {
// Try finding it as the second named child
if n.NamedChildCount() >= 2 {
args = n.NamedChild(1)
}
}
if args == nil {
return nil
}
// Find the alias (module name) in the arguments
var moduleName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
moduleName = GetNodeText(node, content)
return false
}
return true
})
if moduleName == "" {
return nil
}
return &protocol.Symbol{
Name: moduleName,
Kind: protocol.SymbolModule,
Location: NodeLocation(n, filename),
}
}
// extractElixirFunction extracts a function definition.
func extractElixirFunction(n *sitter.Node, content []byte, filename string, isPrivate bool) *protocol.Symbol {
// def function_name(args) do ... end
// The function name and args are in the arguments of the call
if n.NamedChildCount() < 2 {
return nil
}
// Second child contains the function definition
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var funcName string
// The function definition can be:
// 1. A call node (function with args): func_name(arg1, arg2)
// 2. An identifier (function without args): func_name
switch funcDef.Type() {
case "call":
// Get the function name from the call target
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
funcName = GetNodeText(nameNode, content)
}
}
case "identifier":
funcName = GetNodeText(funcDef, content)
case "binary_operator":
// Guard clause: def func_name(args) when guard do ... end
// The left side contains the actual function call
WalkTree(funcDef, func(node *sitter.Node) bool {
if node.Type() == "call" && node.NamedChildCount() >= 1 {
nameNode := node.NamedChild(0)
if nameNode != nil && nameNode.Type() == "identifier" {
funcName = GetNodeText(nameNode, content)
return false
}
}
if node.Type() == "identifier" && funcName == "" {
funcName = GetNodeText(node, content)
return false
}
return true
})
}
if funcName == "" {
return nil
}
kind := protocol.SymbolFunction
if isPrivate {
funcName = funcName + " (private)"
}
return &protocol.Symbol{
Name: funcName,
Kind: kind,
Location: NodeLocation(n, filename),
}
}
// extractElixirMacro extracts a macro definition.
func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Similar to function extraction
if n.NamedChildCount() < 2 {
return nil
}
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var macroName string
switch funcDef.Type() {
case "call":
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
macroName = GetNodeText(nameNode, content)
}
}
case "identifier":
macroName = GetNodeText(funcDef, content)
}
if macroName == "" {
return nil
}
return &protocol.Symbol{
Name: macroName + " (macro)",
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
// extractElixirStruct extracts a struct definition.
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defstruct is typically inside a module, the struct name is the module name
// We just mark this as a struct symbol
return &protocol.Symbol{
Name: "defstruct",
Kind: protocol.SymbolStruct,
Location: NodeLocation(n, filename),
}
}
// extractElixirProtocol extracts a protocol definition.
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defprotocol ProtocolName do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var protocolName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
protocolName = GetNodeText(node, content)
return false
}
return true
})
if protocolName == "" {
return nil
}
return &protocol.Symbol{
Name: protocolName,
Kind: protocol.SymbolInterface,
Location: NodeLocation(n, filename),
}
}
// extractRustSymbols extracts symbols from Rust code.
func extractRustSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol
WalkTree(root, func(n *sitter.Node) bool {
var symbol *protocol.Symbol
switch n.Type() {
case "function_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
case "struct_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolStruct,
Location: NodeLocation(n, filename),
}
}
case "enum_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolEnum,
Location: NodeLocation(n, filename),
}
}
case "trait_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolTrait,
Location: NodeLocation(n, filename),
}
}
case "impl_item":
symbol = extractRustImpl(n, content, filename)
case "type_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolType,
Location: NodeLocation(n, filename),
}
}
case "const_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolConstant,
Location: NodeLocation(n, filename),
}
}
case "static_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolVariable,
Location: NodeLocation(n, filename),
}
}
case "macro_definition":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content) + " (macro)",
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
case "mod_item":
nameNode := n.ChildByFieldName("name")
if nameNode != nil {
symbol = &protocol.Symbol{
Name: GetNodeText(nameNode, content),
Kind: protocol.SymbolModule,
Location: NodeLocation(n, filename),
}
}
}
if symbol != nil {
if doc := ExtractDocComment(n, content, protocol.LangRust); doc != nil {
symbol.Doc = FormatDocComment(doc)
}
symbols = append(symbols, *symbol)
}
return true
})
return symbols
}
// extractRustImpl extracts an impl block symbol from Rust code.
func extractRustImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
typeNode := n.ChildByFieldName("type")
traitNode := n.ChildByFieldName("trait")
var name string
if traitNode != nil && typeNode != nil {
name = "impl " + GetNodeText(traitNode, content) + " for " + GetNodeText(typeNode, content)
} else if typeNode != nil {
name = "impl " + GetNodeText(typeNode, content)
} else {
return nil
}
return &protocol.Symbol{
Name: name,
Kind: protocol.SymbolType,
Location: NodeLocation(n, filename),
}
}
// extractElixirImpl extracts a protocol implementation.
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defimpl Protocol, for: Type do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var implName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
if implName == "" {
implName = GetNodeText(node, content)
} else {
implName = implName + " for " + GetNodeText(node, content)
return false
}
}
return true
})
if implName == "" {
return nil
}
return &protocol.Symbol{
Name: implName,
Kind: protocol.SymbolClass,
Location: NodeLocation(n, filename),
}
}
// kindMatchesNode returns true if the given SymbolKind matches the node type.
// An empty kind matches all symbol-bearing nodes.
func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
if kind == "" {
return true
}
switch kind {
case protocol.SymbolFunction:
return nodeType == "function_declaration" || nodeType == "function_definition" || nodeType == "function_item"
case protocol.SymbolMethod:
return nodeType == "method_declaration" || nodeType == "method_definition"
case protocol.SymbolClass:
return nodeType == "class_declaration" || nodeType == "class_definition"
case protocol.SymbolStruct:
return nodeType == "struct_item" || nodeType == "type_declaration"
case protocol.SymbolInterface:
return nodeType == "interface_declaration"
case protocol.SymbolType:
return nodeType == "type_declaration" || nodeType == "type_alias_declaration" || nodeType == "type_item"
case protocol.SymbolEnum:
return nodeType == "enum_item"
case protocol.SymbolTrait:
return nodeType == "trait_item"
case protocol.SymbolConstant:
return nodeType == "const_item"
case protocol.SymbolModule:
return nodeType == "mod_item"
}
return true
}
// FindSymbolRange finds a named symbol in the AST and returns its line range (1-indexed, inclusive).
// symbolKind filters by symbol kind (e.g. "function", "struct"); empty string matches any kind.
// Returns (0, 0, false) if the symbol is not found.
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
if tree == nil || symbolName == "" {
return
}
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
if found {
return false
}
nameNode := n.ChildByFieldName("name")
if nameNode == nil {
return true
}
if GetNodeText(nameNode, content) != symbolName {
return true
}
switch n.Type() {
case "function_declaration", "method_declaration", "type_declaration",
"function_definition", "class_definition", "class_declaration",
"interface_declaration", "type_alias_declaration",
"function_item", "struct_item", "enum_item", "trait_item",
"type_item", "const_item", "mod_item":
if !kindMatchesNode(symbolKind, n.Type()) {
return true // kind mismatch, keep searching
}
r := NodeRange(n, filename)
startLine = r.Start.Line
endLine = r.End.Line
found = true
return false
}
return true
})
return
}
+93
View File
@@ -224,3 +224,96 @@ int main() {
}
}
}
func TestExtractElixirSymbols(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := `defmodule MyApp.User do
@moduledoc """
User module for the application.
"""
defstruct [:name, :email]
@doc """
Creates a new user.
"""
def new(name, email) do
%__MODULE__{name: name, email: email}
end
defp validate(user) do
# Private validation function
user
end
defmacro is_user(term) do
quote do
is_struct(unquote(term), __MODULE__)
end
end
end
defprotocol Greeting do
@doc "Greet the entity"
def greet(entity)
end
defimpl Greeting, for: MyApp.User do
def greet(user) do
"Hello, #{user.name}!"
end
end
`
ctx := context.Background()
result, err := r.Parse(ctx, "test.ex", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "test.ex")
// Check that we found some symbols
if len(symbols) == 0 {
t.Fatal("expected to find some symbols")
}
// Look for specific expected symbols - we focus on top-level constructs
// that the current implementation can reliably extract
expectedSymbols := map[string]protocol.SymbolKind{
"MyApp.User": protocol.SymbolModule,
"Greeting": protocol.SymbolInterface,
}
found := make(map[string]bool)
for _, sym := range symbols {
for name, expectedKind := range expectedSymbols {
if sym.Name == name {
found[name] = true
if sym.Kind != expectedKind {
t.Errorf("symbol %s: expected kind %s, got %s", sym.Name, expectedKind, sym.Kind)
}
}
}
}
for name := range expectedSymbols {
if !found[name] {
t.Errorf("expected to find symbol %s, found symbols: %v", name, symbols)
}
}
// Verify we found the defstruct
foundStruct := false
for _, sym := range symbols {
if sym.Kind == protocol.SymbolStruct {
foundStruct = true
break
}
}
if !foundStruct {
t.Error("expected to find a struct symbol")
}
}
+4 -4
View File
@@ -31,8 +31,8 @@ type JSONNode struct {
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse YAML
@@ -57,8 +57,8 @@ func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byt
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > MaxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse JSON to validate syntax
+186
View File
@@ -0,0 +1,186 @@
package query
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// makeResults builds N dummy MatchResults.
func makeResults(n int) []MatchResult {
out := make([]MatchResult, n)
for i := range out {
out[i] = MatchResult{
File: "file.go",
Location: protocol.Location{
Line: i + 1,
Column: 1,
},
Text: "func Foo() {}\nline2",
}
}
return out
}
// TestFormatResultsVerboseDefault verifies verbose format includes code blocks (no preamble by default).
func TestFormatResultsVerboseDefault(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0)
// v2 default: no preamble
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "```") {
t.Error("verbose mode should include code blocks")
}
}
// TestFormatResultsVerbosePreamble verifies verbose=true restores the preamble.
func TestFormatResultsVerbosePreamble(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0, true)
if !strings.Contains(out, "Found 2 match(es):") {
t.Errorf("expected preamble with verbose=true, got:\n%s", out)
}
}
func TestFormatResultsCompact(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "compact", 0)
// v2 default: no preamble in compact mode
// Should NOT have code blocks
if strings.Contains(out, "```") {
t.Error("compact mode should not have code blocks")
}
// Should have one line per match (beyond header)
lines := strings.Split(strings.TrimSpace(out), "\n")
// First two lines are "Found..." and blank, then 3 match lines
matchLines := 0
for _, l := range lines {
if strings.Contains(l, "file.go:") {
matchLines++
}
}
if matchLines != 3 {
t.Errorf("expected 3 match lines in compact mode, got %d\nOutput:\n%s", matchLines, out)
}
}
func TestFormatResultsLocation(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "location", 0)
if strings.Contains(out, "```") {
t.Error("location mode should not have code blocks")
}
// Should be file:line only
for i := 1; i <= 3; i++ {
expected := "file.go:" + itoa(i)
if !strings.Contains(out, expected) {
t.Errorf("location output missing %s", expected)
}
}
}
func TestFormatResultsMaxResults(t *testing.T) {
results := makeResults(5)
out := FormatResultsWithOptions(results, 3, "verbose", 0)
// v2 default: no preamble — check that exactly 3 code blocks are present
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 { // 3 opening + 3 closing = 6
t.Errorf("expected 3 matches (6 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 2]") {
t.Errorf("expected [remaining: 2] footer, got:\n%s", out)
}
}
func TestFormatResultsOffset(t *testing.T) {
results := makeResults(5)
// Skip first 2, show all remaining
out := FormatResultsWithOptions(results, 0, "verbose", 2)
// offset=2 from 5 results → 3 results; check 3 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 {
t.Errorf("expected 3 matches (6 backtick markers) after offset=2, got %d in:\n%s", codeBlockCount, out)
}
}
func TestFormatResultsOffsetBeyondEnd(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "verbose", 10)
if out != "No matches found." {
t.Errorf("expected 'No matches found.' for offset beyond end, got: %s", out)
}
}
func TestFormatResultsPaginationCursor(t *testing.T) {
// Offset=2, maxResults=2, 5 total → show items 3&4, remaining=1
results := makeResults(5)
out := FormatResultsWithOptions(results, 2, "verbose", 2)
// offset=2, maxResults=2 → items 3&4; check 2 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 4 {
t.Errorf("expected 2 matches (4 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 1]") {
t.Errorf("expected [remaining: 1], got:\n%s", out)
}
}
func TestFormatResultsEmpty(t *testing.T) {
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
func TestFormatResultsBackwardCompat(t *testing.T) {
// FormatResults wrapper should produce same output as FormatResultsWithOptions with verbose=false (default).
results := makeResults(2)
a := FormatResults(results, 0)
b := FormatResultsWithOptions(results, 0, "verbose", 0)
if a != b {
t.Error("FormatResults and FormatResultsWithOptions(verbose,0) should be identical")
}
// Both should have no preamble.
if strings.Contains(a, "Found ") {
t.Error("FormatResults should not emit preamble by default")
}
}
func TestFirstLineOf(t *testing.T) {
cases := []struct {
input string
maxLen int
want string
}{
{"hello world", 20, "hello world"},
{"line1\nline2", 20, "line1"},
{"\n\nfoo", 20, "foo"},
{"abcdefghij", 5, "abcd…"},
}
for _, c := range cases {
got := firstLineOf(c.input, c.maxLen)
if got != c.want {
t.Errorf("firstLineOf(%q, %d) = %q, want %q", c.input, c.maxLen, got, c.want)
}
}
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return strings.TrimRight(strings.TrimRight(
func() string {
buf := make([]byte, 20)
pos := 20
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
return string(buf[pos:])
}(), ""), "")
}
+107 -74
View File
@@ -6,37 +6,13 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// Global regex cache for compiled patterns (thread-safe)
var regexCache sync.Map // string -> *regexp.Regexp
// compileRegex compiles a regex pattern with caching for performance.
// Cached patterns avoid repeated compilation overhead (10-50x speedup).
// Thread-safe: uses LoadOrStore to prevent race conditions.
func compileRegex(pattern string) (*regexp.Regexp, error) {
// Check cache first
if cached, ok := regexCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
// Compile regex
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
// Try to store - if another goroutine already stored it, use theirs
// This prevents race conditions where multiple goroutines compile the same pattern
actual, _ := regexCache.LoadOrStore(pattern, re)
return actual.(*regexp.Regexp), nil
}
// ASTQuery defines a query for matching AST patterns.
type ASTQuery struct {
Pattern string `json:"pattern"` // code pattern with $VAR placeholders
@@ -46,13 +22,11 @@ type ASTQuery struct {
// QueryFilters provide additional filtering criteria.
type QueryFilters struct {
HasChild *ASTQuery `json:"has_child,omitempty"`
HasParent *ASTQuery `json:"has_parent,omitempty"`
NameMatches string `json:"name_matches,omitempty"`
NameExact string `json:"name_exact,omitempty"`
InFile string `json:"in_file,omitempty"`
NotInFile string `json:"not_in_file,omitempty"`
KindIn []string `json:"kind_in,omitempty"`
NameMatches string `json:"name_matches,omitempty"`
NameExact string `json:"name_exact,omitempty"`
InFile string `json:"in_file,omitempty"`
NotInFile string `json:"not_in_file,omitempty"`
KindIn []string `json:"kind_in,omitempty"`
}
// MatchResult represents a single match from a query.
@@ -283,7 +257,7 @@ func matchPatternHeuristic(node *sitter.Node, pattern *ParsedPattern, content []
}
// Match struct patterns (Go, C, C++)
if strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ") && strings.Contains(patternLower, "struct") {
if (strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ")) && strings.Contains(patternLower, "struct") {
if nodeType != "type_declaration" && nodeType != "struct_specifier" {
return false
}
@@ -438,7 +412,7 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
return false
}
name := parser.GetNodeText(nameNode, content)
re, err := compileRegex(filters.NameMatches)
re, err := util.CompileRegex(filters.NameMatches)
if err != nil {
return false
}
@@ -477,62 +451,121 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
return true
}
// FormatResults formats match results for display.
// FormatResults formats match results for display (backward-compat wrapper, verbose mode).
func FormatResults(results []MatchResult, maxResults int) string {
return FormatResultsWithOptions(results, maxResults, "verbose", 0)
}
// FormatResultsWithOptions formats match results with configurable output format.
// format: "verbose" (default) | "compact" | "location"
// offset: skip this many results before rendering (used for cursor pagination).
// verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour).
func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string {
if len(results) == 0 {
return "No matches found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d match(es):\n\n", len(results)))
displayCount := len(results)
truncated := false
if maxResults > 0 && displayCount > maxResults {
displayCount = maxResults
truncated = true
// Apply offset (pagination skip).
if offset > 0 {
if offset >= len(results) {
return "No matches found."
}
results = results[offset:]
}
for i := 0; i < displayCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
// Determine how many to render and whether more remain.
renderCount := len(results)
remaining := 0
if maxResults > 0 && renderCount > maxResults {
remaining = renderCount - maxResults
renderCount = maxResults
}
// Truncate very long text
text := r.Text
if len(text) > 500 {
text = text[:500] + "..."
}
sb.WriteString("```\n")
sb.WriteString(text)
sb.WriteString("\n```\n")
var sb strings.Builder
// Show captures
if len(r.Captures) > 0 {
sb.WriteString("Captures: ")
first := true
for name, cap := range r.Captures {
if !first {
sb.WriteString(", ")
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
wantVerbose := len(verbose) > 0 && verbose[0]
if wantVerbose {
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
}
switch format {
case "compact":
for i := 0; i < renderCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
firstLine := firstLineOf(r.Text, 80)
sb.WriteString(fmt.Sprintf("%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine))
}
case "location":
for i := 0; i < renderCount; i++ {
r := results[i]
sb.WriteString(fmt.Sprintf("%s:%d\n", r.File, r.Location.Line))
}
default: // "verbose"
for i := 0; i < renderCount; i++ {
r := results[i]
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
// Truncate very long text
text := r.Text
if len(text) > 500 {
text = text[:500] + "..."
}
sb.WriteString("```\n")
sb.WriteString(text)
sb.WriteString("\n```\n")
// Show captures
if len(r.Captures) > 0 {
sb.WriteString("Captures: ")
first := true
for name, cap := range r.Captures {
if !first {
sb.WriteString(", ")
}
first = false
capText := cap.Text
if len(capText) > 50 {
capText = capText[:50] + "..."
}
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
}
first = false
capText := cap.Text
if len(capText) > 50 {
capText = capText[:50] + "..."
}
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("\n")
}
if truncated {
sb.WriteString(fmt.Sprintf("... and %d more matches (truncated)\n", len(results)-maxResults))
if remaining > 0 {
// Caller must embed the cursor token; we just append the remaining count hint.
// The actual [cursor: ...] line is written by the handler after calling MakeCursor.
sb.WriteString(fmt.Sprintf("[remaining: %d]\n", remaining))
}
return sb.String()
}
// firstLineOf returns the first non-empty line of s, trimmed and capped at maxLen chars.
func firstLineOf(s string, maxLen int) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if len(line) > maxLen {
return line[:maxLen-1] + "…"
}
return line
}
return ""
}
+149
View File
@@ -499,6 +499,155 @@ func TestFormatResults(t *testing.T) {
}
}
// TestMatchStructOperatorPrecedence verifies the C-07 operator precedence fix.
// Before the fix, patterns like "struct Foo" would match because
// strings.Contains(p, "struct ") short-circuited the entire condition.
// After the fix, both "struct" must be present for the struct branch to match.
func TestMatchStructOperatorPrecedence(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
matcher := NewMatcher(reg)
content := `package main
type Server struct {
Port int
}
func main() {}
`
ctx := context.Background()
result, err := reg.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
tests := []struct {
name string
pattern string
wantMatches int
}{
{
name: "type struct pattern should match",
pattern: "type $NAME struct { $$$FIELDS }",
wantMatches: 1, // Server
},
{
name: "struct keyword alone should match",
pattern: "struct $NAME { $$$FIELDS }",
wantMatches: 1, // Server
},
{
name: "func pattern should not match struct branch",
pattern: "func $NAME() {}",
wantMatches: 1, // main (matches function branch, not struct)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := &ASTQuery{
Pattern: tt.pattern,
Language: "go",
}
results, err := matcher.Match(ctx, query, result.Tree, []byte(content), "test.go")
if err != nil {
t.Fatalf("match failed: %v", err)
}
if len(results) != tt.wantMatches {
t.Errorf("expected %d matches, got %d", tt.wantMatches, len(results))
for i, r := range results {
t.Logf("match %d: type=%s, text=%q", i, r.Node.Type(), truncateForLog(r.Text, 80))
}
}
})
}
}
func truncateForLog(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
// TestPassesFilters_AllBranches tests passesFilters for each filter type.
func TestPassesFilters_AllBranches(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
content := `package main
func Alpha() {}
func Beta() {}
func Gamma() {}
`
ctx := context.Background()
result, err := reg.Parse(ctx, "test.go", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
matcher := NewMatcher(reg)
tests := []struct {
name string
filters QueryFilters
wantMatches int
}{
{
name: "no filters matches all",
filters: QueryFilters{},
wantMatches: 3,
},
{
name: "name_exact filter",
filters: QueryFilters{NameExact: "Alpha"},
wantMatches: 1,
},
{
name: "name_matches regex filter",
filters: QueryFilters{NameMatches: "^[AB]"},
wantMatches: 2,
},
{
name: "kind_in filter",
filters: QueryFilters{KindIn: []string{"function_declaration"}},
wantMatches: 3,
},
{
name: "kind_in filter excludes non-matching kinds",
filters: QueryFilters{KindIn: []string{"class_declaration"}},
wantMatches: 0,
},
{
name: "combined name_exact and kind_in",
filters: QueryFilters{NameExact: "Beta", KindIn: []string{"function_declaration"}},
wantMatches: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query := &ASTQuery{
Pattern: "func $NAME() {}",
Language: "go",
Filters: tt.filters,
}
results, err := matcher.Match(ctx, query, result.Tree, []byte(content), "test.go")
if err != nil {
t.Fatalf("match failed: %v", err)
}
if len(results) != tt.wantMatches {
t.Errorf("expected %d matches, got %d", tt.wantMatches, len(results))
}
})
}
}
func TestQueryValidation(t *testing.T) {
reg := parser.NewRegistry()
defer reg.Close()
+36 -41
View File
@@ -4,23 +4,25 @@ import (
"regexp"
"sync"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
)
// TestCompileRegexCaching tests that regex compilation is cached.
func TestCompileRegexCaching(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `^test_\w+$`
// First compilation
re1, err := compileRegex(pattern)
re1, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("First compile failed: %v", err)
}
// Second compilation should return cached version
re2, err := compileRegex(pattern)
re2, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Second compile failed: %v", err)
}
@@ -29,22 +31,12 @@ func TestCompileRegexCaching(t *testing.T) {
if re1 != re2 {
t.Error("Expected cached regex to be reused, got different objects")
}
// Verify it's in the cache
cached, ok := regexCache.Load(pattern)
if !ok {
t.Error("Pattern not found in cache")
}
if cached.(*regexp.Regexp) != re1 {
t.Error("Cached regex doesn't match returned regex")
}
}
// TestCompileRegexConcurrent tests concurrent regex compilation.
func TestCompileRegexConcurrent(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `[a-z]+_\d+`
const numGoroutines = 100
@@ -60,7 +52,7 @@ func TestCompileRegexConcurrent(t *testing.T) {
go func() {
defer wg.Done()
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
errors <- err
return
@@ -89,26 +81,30 @@ func TestCompileRegexConcurrent(t *testing.T) {
// TestCompileRegexInvalidPattern tests error handling for invalid patterns.
func TestCompileRegexInvalidPattern(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
invalidPattern := `[invalid(`
_, err := compileRegex(invalidPattern)
_, err := util.CompileRegex(invalidPattern)
if err == nil {
t.Error("Expected error for invalid pattern, got nil")
}
// Invalid patterns should not be cached
_, ok := regexCache.Load(invalidPattern)
if ok {
t.Error("Invalid pattern should not be cached")
// Verify that a valid pattern still works after an invalid one
validPattern := `^valid$`
re, err := util.CompileRegex(validPattern)
if err != nil {
t.Errorf("Expected valid pattern to compile, got error: %v", err)
}
if re == nil {
t.Error("Expected non-nil regex for valid pattern")
}
}
// TestCompileRegexMultiplePatterns tests that different patterns are cached separately.
func TestCompileRegexMultiplePatterns(t *testing.T) {
// Clear cache before test
regexCache = sync.Map{}
util.ClearRegexCache()
patterns := []string{
`^test_\w+$`,
@@ -121,26 +117,14 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
// Compile all patterns
for i, pattern := range patterns {
re, err := compileRegex(pattern)
re, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Compile failed for pattern %s: %v", pattern, err)
}
compiled[i] = re
}
// Verify all are cached
for i, pattern := range patterns {
cached, ok := regexCache.Load(pattern)
if !ok {
t.Errorf("Pattern %s not in cache", pattern)
}
if cached.(*regexp.Regexp) != compiled[i] {
t.Errorf("Cached regex for %s doesn't match compiled version", pattern)
}
}
// All should be different objects
// All should be different objects (different patterns)
for i := 0; i < len(compiled); i++ {
for j := i + 1; j < len(compiled); j++ {
if compiled[i] == compiled[j] {
@@ -148,6 +132,17 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
}
}
}
// Re-compile should return cached versions
for i, pattern := range patterns {
re, err := util.CompileRegex(pattern)
if err != nil {
t.Fatalf("Re-compile failed for pattern %s: %v", pattern, err)
}
if re != compiled[i] {
t.Errorf("Pattern %s was not cached properly", pattern)
}
}
}
// BenchmarkCompileRegex_Uncached benchmarks regex compilation without caching.
@@ -163,23 +158,23 @@ func BenchmarkCompileRegex_Uncached(b *testing.B) {
// BenchmarkCompileRegex_Cached benchmarks regex compilation with caching.
func BenchmarkCompileRegex_Cached(b *testing.B) {
// Clear cache
regexCache = sync.Map{}
util.ClearRegexCache()
pattern := `^\w+_[0-9]{3,5}_[a-zA-Z]+$`
// Pre-populate cache
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
}
}
// BenchmarkCompileRegex_MixedPatterns benchmarks realistic workload with multiple patterns.
func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
// Clear cache
regexCache = sync.Map{}
util.ClearRegexCache()
patterns := []string{
`^test_\w+$`,
@@ -193,6 +188,6 @@ func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
for i := 0; i < b.N; i++ {
// Simulate realistic access pattern
pattern := patterns[i%len(patterns)]
_, _ = compileRegex(pattern)
_, _ = util.CompileRegex(pattern)
}
}
+131
View File
@@ -0,0 +1,131 @@
package search
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"log/slog"
"os"
)
func newTestSearcher(t *testing.T) *Searcher {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: t.TempDir(),
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
// Create a Searcher directly without requiring rg
return &Searcher{cfg: cfg, logger: logger, rgPath: "rg"}
}
func makeSearchResults(lines []int) *SearchResults {
results := make([]Result, len(lines))
for i, l := range lines {
results[i] = Result{
File: "/workspace/foo.go",
Line: l,
MatchText: "match at line " + itoa2(l),
}
}
return &SearchResults{Results: results}
}
// itoa2 simple int to string for test
func itoa2(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// TestFormatResultsVerboseBackwardCompat verifies that Verbose=true restores the v1 preamble.
func TestFormatResultsVerboseBackwardCompat(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
// With Verbose=true, the preamble is emitted (v1 behaviour).
out := s.FormatResultsWithOptions(sr, FormatOptions{Verbose: true})
if !strings.Contains(out, "Found 3 matches in 1 files") {
t.Errorf("expected header with Verbose=true, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
// TestFormatResultsDefaultNoPreamble verifies the v2 default has no preamble.
func TestFormatResultsDefaultNoPreamble(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResults(sr)
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
func TestFormatResultsClusterSingleLine(t *testing.T) {
s := newTestSearcher(t)
// Non-consecutive lines → each should appear separately
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L5│") {
t.Errorf("expected L5 cluster, got:\n%s", out)
}
// Should NOT have " │" context lines in cluster mode
if strings.Contains(out, " │") {
t.Errorf("cluster mode should not have context-line decoration, got:\n%s", out)
}
}
func TestFormatResultsClusterConsecutive(t *testing.T) {
s := newTestSearcher(t)
// Lines 3,4,5 are consecutive → should be clustered as L3-5
sr := makeSearchResults([]int{3, 4, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L3-5│") {
t.Errorf("expected L3-5 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L10│") {
t.Errorf("expected L10 separate cluster, got:\n%s", out)
}
}
func TestFormatResultsClusterAdjacentMerge(t *testing.T) {
s := newTestSearcher(t)
// Lines 7 and 8 differ by 1 → merge
sr := makeSearchResults([]int{7, 8})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L7-8│") {
t.Errorf("expected L7-8 cluster, got:\n%s", out)
}
}
func TestFormatResultsCursorLine(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1})
cursorText := "[cursor: abc123, remaining: 5]"
out := s.FormatResultsWithOptions(sr, FormatOptions{CursorLine: cursorText})
if !strings.Contains(out, cursorText) {
t.Errorf("expected cursor footer in output, got:\n%s", out)
}
}
func TestFormatResultsNoMatchesVerbose(t *testing.T) {
s := newTestSearcher(t)
sr := &SearchResults{Results: nil}
out := s.FormatResults(sr)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
+138 -44
View File
@@ -131,6 +131,11 @@ func (s *Searcher) Search(ctx context.Context, req *Request) (*SearchResults, er
WithRemediation("Provide a non-empty search pattern")
}
// Validate that at least one provided path is allowed
if err := s.validatePaths(req.Paths); err != nil {
return nil, err
}
// Build ripgrep command
args := s.buildArgs(req)
@@ -214,10 +219,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
args = append(args, "--no-ignore")
}
// Max count per file to limit results
if req.MaxResults > 0 {
args = append(args, fmt.Sprintf("--max-count=%d", req.MaxResults))
}
// Result cap enforced in-process by parseOutput. rg has no cross-file
// total-count flag in stable releases, so we don't pass one; --max-count is
// per-file and would miss results unevenly.
// Add pattern
args = append(args, "--", req.Pattern)
@@ -237,15 +241,31 @@ func (s *Searcher) buildArgs(req *Request) []string {
return args
}
// validatePaths checks that at least one caller-provided path is allowed.
// Returns an error if paths were provided but none passed IsPathAllowed.
func (s *Searcher) validatePaths(paths []string) error {
if len(paths) == 0 {
return nil // no explicit paths — will default to workspace root
}
for _, p := range paths {
if s.cfg.IsPathAllowed(p) {
return nil
}
}
return errors.New(errors.ErrPathNotAllowed, "all provided search paths are outside the workspace root").
WithContext("paths", fmt.Sprintf("%v", paths)).
WithRemediation("Provide paths within the workspace root")
}
// parseOutput parses ripgrep JSON output.
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
results := &SearchResults{
Results: []Result{},
}
// Track context by file and line
contextBefore := make(map[string][]string) // file -> lines before current match
currentFile := ""
// Track before-context lines linearly: accumulate context lines until the next match consumes them.
var pendingBefore []string
pendingFile := ""
scanner := bufio.NewScanner(output)
for scanner.Scan() {
@@ -285,14 +305,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
result.Column = match.Submatches[0].Start + 1 // 1-indexed
}
// Add context before
if ctx, ok := contextBefore[match.Path.Text]; ok {
result.Context.Before = ctx
delete(contextBefore, match.Path.Text)
// Attach pending before-context if it belongs to this file
if pendingFile == match.Path.Text && len(pendingBefore) > 0 {
result.Context.Before = pendingBefore
}
pendingBefore = nil
pendingFile = ""
results.Results = append(results.Results, result)
currentFile = match.Path.Text
case "context":
var ctx rgContext
@@ -302,19 +322,20 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
lineText := strings.TrimRight(ctx.Lines.Text, "\n\r")
// Determine if this is before or after context
isAfter := false
if len(results.Results) > 0 {
lastResult := &results.Results[len(results.Results)-1]
if lastResult.File == ctx.Path.Text && ctx.LineNumber > lastResult.Line {
// This is after context
lastResult.Context.After = append(lastResult.Context.After, lineText)
} else if ctx.Path.Text == currentFile || currentFile == "" {
// This is before context for a potential upcoming match
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
last := &results.Results[len(results.Results)-1]
if last.File == ctx.Path.Text && ctx.LineNumber > last.Line {
last.Context.After = append(last.Context.After, lineText)
isAfter = true
}
} else {
// Before any match - store as potential before context
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
}
if !isAfter {
if pendingFile != ctx.Path.Text {
pendingBefore = nil
pendingFile = ctx.Path.Text
}
pendingBefore = append(pendingBefore, lineText)
}
case "summary":
@@ -333,8 +354,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
return results, nil
}
// FormatResults formats search results for display.
// FormatOptions controls how search results are rendered.
type FormatOptions struct {
Cluster bool // coalesce consecutive matches into line-range blocks
CursorLine string // if non-empty, appended as a footer line
Verbose bool // if true, emit "Found N matches in M files:" preamble (opt-in)
}
// FormatResults formats search results for display (backward-compat wrapper).
func (s *Searcher) FormatResults(results *SearchResults) string {
return s.FormatResultsWithOptions(results, FormatOptions{})
}
// FormatResultsWithOptions formats search results with configurable output.
// By default the "Found N matches in M files:" preamble is omitted; set opts.Verbose=true to restore it.
func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatOptions) string {
if len(results.Results) == 0 {
return "No matches found."
}
@@ -351,14 +385,18 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
fileResults[r.File] = append(fileResults[r.File], r)
}
// Write summary
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
// Write preamble only when Verbose is requested.
if opts.Verbose {
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
}
sb.WriteString(":\n\n")
} else if results.Truncated {
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
}
sb.WriteString(":\n\n")
// Write results grouped by file
for _, file := range fileOrder {
@@ -372,26 +410,82 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
for _, r := range fileResults[file] {
// Write context before
for _, ctx := range r.Context.Before {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
// Write match line
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
// Write context after
for _, ctx := range r.Context.After {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
if opts.Cluster {
writeClusteredResults(&sb, fileResults[file])
} else {
writeVerboseResults(&sb, fileResults[file])
}
sb.WriteString("\n")
}
if opts.CursorLine != "" {
sb.WriteString(opts.CursorLine)
sb.WriteString("\n")
}
return sb.String()
}
// writeVerboseResults writes results in the standard verbose format.
func writeVerboseResults(sb *strings.Builder, results []Result) {
for _, r := range results {
// Write context before
for _, ctx := range r.Context.Before {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
// Write match line
fmt.Fprintf(sb, "L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200))
// Write context after
for _, ctx := range r.Context.After {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
}
}
// writeClusteredResults coalesces consecutive or adjacent match lines into
// a single "L12-14│ <first-match-text>" entry. Context lines are dropped
// in cluster mode to maximise information density.
func writeClusteredResults(sb *strings.Builder, results []Result) {
if len(results) == 0 {
return
}
type clusterEntry struct {
startLine int
endLine int
firstText string
}
var clusters []clusterEntry
cur := clusterEntry{
startLine: results[0].Line,
endLine: results[0].Line,
firstText: results[0].MatchText,
}
for _, r := range results[1:] {
// Merge if adjacent (within 1 line gap)
if r.Line <= cur.endLine+1 {
if r.Line > cur.endLine {
cur.endLine = r.Line
}
} else {
clusters = append(clusters, cur)
cur = clusterEntry{startLine: r.Line, endLine: r.Line, firstText: r.MatchText}
}
}
clusters = append(clusters, cur)
for _, c := range clusters {
text := truncateLine(c.firstText, 200)
if c.startLine == c.endLine {
fmt.Fprintf(sb, "L%d│ %s\n", c.startLine, text)
} else {
fmt.Fprintf(sb, "L%d-%d│ %s\n", c.startLine, c.endLine, text)
}
}
}
// truncateLine truncates a line if it exceeds maxLen.
func truncateLine(s string, maxLen int) string {
if len(s) <= maxLen {
+26 -10
View File
@@ -43,9 +43,10 @@ func TestBuildArgs(t *testing.T) {
}
tests := []struct {
name string
req *Request
expected []string
name string
req *Request
expected []string
notExpected []string // T-06: verify absence of unexpected flags
}{
{
name: "basic search",
@@ -54,7 +55,8 @@ func TestBuildArgs(t *testing.T) {
ContextLines: 2,
Regex: true,
},
expected: []string{"--json", "--context=2", "--", "test", "."},
expected: []string{"--json", "--context=2", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=0"},
},
{
name: "ignore case",
@@ -63,7 +65,8 @@ func TestBuildArgs(t *testing.T) {
IgnoreCase: true,
Regex: true,
},
expected: []string{"--json", "--ignore-case", "--", "test", "."},
expected: []string{"--json", "--ignore-case", "--", "test", "."},
notExpected: []string{"--fixed-strings"},
},
{
name: "fixed strings",
@@ -71,7 +74,8 @@ func TestBuildArgs(t *testing.T) {
Pattern: "test",
Regex: false,
},
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
notExpected: []string{"--ignore-case"},
},
{
name: "with file types",
@@ -80,7 +84,8 @@ func TestBuildArgs(t *testing.T) {
FileTypes: []string{"go", "ts"},
Regex: true,
},
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings"},
},
{
name: "with max results",
@@ -89,7 +94,8 @@ func TestBuildArgs(t *testing.T) {
MaxResults: 10,
Regex: true,
},
expected: []string{"--json", "--max-count=10", "--", "test", "."},
expected: []string{"--json", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
},
}
@@ -110,6 +116,16 @@ func TestBuildArgs(t *testing.T) {
t.Errorf("expected arg %q not found in %v", exp, args)
}
}
// T-06: Check that unexpected args are absent
for _, notExp := range tt.notExpected {
for _, arg := range args {
if arg == notExp {
t.Errorf("unexpected arg %q found in %v", notExp, args)
break
}
}
}
})
}
}
@@ -259,7 +275,7 @@ func TestSearchIntegration(t *testing.T) {
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Create test files
testFile := filepath.Join(tmpDir, "test.go")
@@ -269,7 +285,7 @@ func main() {
println("Hello, World!")
}
`
err = os.WriteFile(testFile, []byte(content), 0600)
err = os.WriteFile(testFile, []byte(content), 0o600)
if err != nil {
t.Fatalf("failed to write test file: %v", err)
}
+344
View File
@@ -0,0 +1,344 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// newTestServer creates a server with a temp workspace containing Go files.
func newFeaturesServer(t *testing.T) (*Server, string) {
t.Helper()
tmpDir := t.TempDir()
// Write a few Go files for AST queries
goFile1 := filepath.Join(tmpDir, "a.go")
if err := os.WriteFile(goFile1, []byte(`package main
func Alpha() string { return "alpha" }
func Beta() string { return "beta" }
func Gamma() string { return "gamma" }
`), 0o600); err != nil {
t.Fatal(err)
}
goFile2 := filepath.Join(tmpDir, "b.go")
if err := os.WriteFile(goFile2, []byte(`package main
func Delta() int { return 1 }
func Epsilon() int { return 2 }
`), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
MaxParseSize: config.DefaultMaxParseSize,
SearchTimeout: 30 * time.Second,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error: %v", err)
}
return srv, tmpDir
}
// ---- Feature 1: ast_query format flag ----
func TestASTQueryFormatVerboseDefault(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result")
}
text := res.Content[0].(mcp.TextContent).Text
// verbose mode has code blocks
if !strings.Contains(text, "```") {
t.Errorf("verbose mode should have code blocks, got:\n%s", text)
}
}
func TestASTQueryFormatCompact(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "compact",
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if strings.Contains(text, "```") {
t.Errorf("compact mode should NOT have code blocks, got:\n%s", text)
}
// Each line should contain file:line (kind) text
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
if line == "" || strings.HasPrefix(line, "Found") {
continue
}
if !strings.Contains(line, ":") {
t.Errorf("compact line missing ':' separator: %q", line)
}
}
}
func TestASTQueryFormatLocation(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "location",
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if strings.Contains(text, "```") {
t.Errorf("location mode should NOT have code blocks, got:\n%s", text)
}
// Lines should be file:linenum only (no parentheses with kind)
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
if line == "" || strings.HasPrefix(line, "Found") {
continue
}
if strings.Contains(line, "(") {
t.Errorf("location mode should not have node type in parens: %q", line)
}
}
}
// ---- Feature 3 (ast_query): pagination cursor ----
func TestASTQueryPaginationCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
// Page 1: max_results=2
req1 := mcp.CallToolRequest{}
req1.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() $RET",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": float64(2),
}
res1, err := srv.handleASTQuery(ctx, req1)
if err != nil {
t.Fatalf("page1 error: %v", err)
}
text1 := res1.Content[0].(mcp.TextContent).Text
// Should contain cursor footer if there are more results
if !strings.Contains(text1, "[cursor:") {
// Might have fewer than 2 total results — skip cursor test
t.Logf("no cursor footer (fewer than 2 total matches), skipping pagination round-trip")
return
}
// Extract cursor token
var cursorToken string
for _, line := range strings.Split(text1, "\n") {
if strings.HasPrefix(line, "[cursor:") {
// [cursor: <token>, remaining: N]
parts := strings.Split(line, " ")
if len(parts) >= 2 {
cursorToken = strings.TrimSuffix(parts[1], ",")
}
break
}
}
if cursorToken == "" {
t.Fatal("could not extract cursor token from output")
}
// Page 2: pass cursor back
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() $RET",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": float64(2),
"cursor": cursorToken,
}
res2, err := srv.handleASTQuery(ctx, req2)
if err != nil {
t.Fatalf("page2 error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if strings.Contains(text2, "cursor is for a different query") {
t.Error("cursor was rejected as mismatched query")
}
// Page 2 should have results
if strings.Contains(text2, "No matches found.") {
t.Error("page2 should have some results")
}
}
func TestASTQueryCursorStaleMismatch(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
"cursor": "eyJvZmZzZXQiOjIsInF1ZXJ5X2hhc2giOiJkZWFkYmVlZiJ9", // offset=2, hash=deadbeef
}
res, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "cursor is for a different query") {
t.Errorf("expected stale cursor error, got:\n%s", text)
}
}
// ---- Feature 2: file_search cluster ----
func TestFileSearchClusterFlag(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"cluster": true,
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || len(res.Content) == 0 {
t.Fatal("nil/empty result")
}
text := res.Content[0].(mcp.TextContent).Text
if text == "No matches found." {
t.Skip("no matches found (unexpected)")
}
// In cluster mode, should NOT have " │" context decorations
if strings.Contains(text, " │") {
t.Errorf("cluster mode should not contain context-line decoration ' │', got:\n%s", text)
}
}
func TestFileSearchCursorStaleHash(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"cursor": "eyJvZmZzZXQiOjEsInF1ZXJ5X2hhc2giOiJiYWRoYXNoIn0", // offset=1, hash=badhash
}
res, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(text, "cursor is for a different query") {
t.Errorf("expected stale cursor error, got:\n%s", text)
}
}
func TestFileSearchPaginationCursor(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if srv.searcher == nil {
t.Skip("rg not available")
}
ctx := context.Background()
// Page 1: get 1 result
req1 := mcp.CallToolRequest{}
req1.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
}
res1, err := srv.handleFileSearch(ctx, req1)
if err != nil {
t.Fatalf("page1 error: %v", err)
}
text1 := res1.Content[0].(mcp.TextContent).Text
if !strings.Contains(text1, "[cursor:") {
t.Logf("no cursor in page1 (only 1 total result), skipping round-trip:\n%s", text1)
return
}
// Extract cursor
var cursorToken string
for _, line := range strings.Split(text1, "\n") {
if strings.HasPrefix(line, "[cursor:") {
parts := strings.Split(line, " ")
if len(parts) >= 2 {
cursorToken = strings.TrimSuffix(parts[1], ",")
}
break
}
}
if cursorToken == "" {
t.Fatal("could not extract cursor from page1")
}
// Page 2
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
"max_results": float64(1),
"context_lines": float64(0),
"cursor": cursorToken,
}
res2, err := srv.handleFileSearch(ctx, req2)
if err != nil {
t.Fatalf("page2 error: %v", err)
}
text2 := res2.Content[0].(mcp.TextContent).Text
if strings.Contains(text2, "cursor is for a different query") {
t.Error("cursor was rejected as mismatched")
}
if strings.Contains(text2, "No matches found.") {
t.Error("page2 should have matches")
}
}
+336
View File
@@ -0,0 +1,336 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/cursor"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/query"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp"
)
// astQueryParams holds resolved parameters for an AST query invocation.
type astQueryParams struct {
pattern string
language string
nameMatches string
nameExact string
kindIn []string
paths []string
maxResults int
format string
offset int
queryHash string
verbose bool
}
// resolveASTQueryParams parses the request and resolves session-pref defaults.
// Returns (params, errorResult, error); when errorResult is non-nil, the caller
// should return it directly.
func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryParams, *mcp.CallToolResult) {
pattern, err := request.RequireString("pattern")
if err != nil {
return nil, mcp.NewToolResultError("pattern is required")
}
language, err := request.RequireString("language")
if err != nil {
return nil, mcp.NewToolResultError("language is required")
}
p := &astQueryParams{
pattern: pattern,
language: language,
nameMatches: request.GetString("name_matches", ""),
nameExact: request.GetString("name_exact", ""),
kindIn: request.GetStringSlice("kind_in", nil),
paths: request.GetStringSlice("paths", nil),
verbose: request.GetBool("verbose", false),
}
sp := s.sessionPrefs.Load()
var prefsMaxResults int
var prefsFormat string
if sp != nil {
prefsMaxResults = sp.DefaultMaxResults
prefsFormat = sp.ASTQueryFormat
}
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
p.format = request.GetString("format", "")
if p.format == "" {
if prefsFormat != "" {
p.format = prefsFormat
} else {
p.format = "verbose"
}
}
p.queryHash = cursor.HashParams(map[string]string{
"pattern": p.pattern,
"language": p.language,
"name_matches": p.nameMatches,
"name_exact": p.nameExact,
"kind_in": strings.Join(p.kindIn, ","),
"paths": strings.Join(p.paths, ","),
})
if cursorStr := request.GetString("cursor", ""); cursorStr != "" {
off, hash, decErr := cursor.Decode(cursorStr)
if decErr != nil {
return nil, mcp.NewToolResultError(fmt.Sprintf("invalid cursor: %s", decErr))
}
if hash != p.queryHash {
return nil, mcp.NewToolResultError("cursor is for a different query, re-run without cursor")
}
p.offset = off
}
return p, nil
}
// runASTQueryWalk walks the configured paths and collects matches.
func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []string) []query.MatchResult {
astQuery := &query.ASTQuery{
Pattern: p.pattern,
Language: p.language,
Filters: query.QueryFilters{
NameMatches: p.nameMatches,
NameExact: p.nameExact,
KindIn: p.kindIn,
},
}
// Collect limit for early-exit: when paginating we need all results first.
collectLimit := p.maxResults
if p.offset > 0 {
collectLimit = 0
}
var allResults []query.MatchResult
for _, searchPath := range p.paths {
if !s.cfg.IsPathAllowed(searchPath) {
continue
}
walkErr := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err != nil {
return nil
}
if info.IsDir() {
if strings.HasPrefix(info.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if !hasAnySuffix(path, exts) {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
if int64(len(content)) > s.cfg.MaxFileSize {
return nil
}
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return nil
}
matches, err := s.matcher.Match(ctx, astQuery, result.Tree, content, path)
if err != nil {
return nil
}
allResults = append(allResults, matches...)
if collectLimit > 0 && len(allResults) >= collectLimit {
return filepath.SkipAll
}
return nil
})
if walkErr != nil {
s.logger.Warn("error walking path", "path", searchPath, "error", walkErr)
}
}
return allResults
}
// hasAnySuffix reports whether path ends with any of the given suffixes.
func hasAnySuffix(path string, suffixes []string) bool {
for _, ext := range suffixes {
if strings.HasSuffix(path, ext) {
return true
}
}
return false
}
// buildASTCursorFooter computes the cursor footer line for truncated results.
func buildASTCursorFooter(total, offset, maxResults int, queryHash string) string {
if offset > 0 && offset < total {
totalAfterOffset := total - offset
if maxResults > 0 && totalAfterOffset > maxResults {
remaining := totalAfterOffset - maxResults
nextOffset := offset + maxResults
nextCursor := cursor.Encode(nextOffset, queryHash)
return fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
}
} else if offset == 0 && maxResults > 0 && total > maxResults {
remaining := total - maxResults
nextCursor := cursor.Encode(maxResults, queryHash)
return fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
}
return ""
}
// handleASTQuery handles the ast_query tool.
func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Acquire semaphore to limit concurrent queries (prevents CPU exhaustion)
select {
case s.querySem <- struct{}{}:
defer func() { <-s.querySem }()
case <-ctx.Done():
return mcp.NewToolResultError("request cancelled"), nil
}
p, errResult := s.resolveASTQueryParams(request)
if errResult != nil {
return errResult, nil
}
if len(p.paths) == 0 {
p.paths = []string{s.cfg.WorkspaceRoot}
}
exts := languageToExtensions(p.language)
if len(exts) == 0 {
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s (supported: go, typescript, javascript, python, c, cpp, html, vue, elixir, rust)", p.language)), nil
}
allResults := s.runASTQueryWalk(ctx, p, exts)
cursorFooter := buildASTCursorFooter(len(allResults), p.offset, p.maxResults, p.queryHash)
output := query.FormatResultsWithOptions(allResults, p.maxResults, p.format, p.offset, p.verbose)
if cursorFooter != "" {
// Replace the [remaining: N] placeholder emitted by FormatResultsWithOptions
// with the full [cursor: ..., remaining: N] line.
output = strings.ReplaceAll(output, fmt.Sprintf("[remaining: %d]\n", len(allResults)-p.offset-p.maxResults), cursorFooter+"\n")
// Fallback: if placeholder wasn't present (e.g. format=location), append footer.
if !strings.Contains(output, cursorFooter) {
output = strings.TrimRight(output, "\n") + "\n" + cursorFooter + "\n"
}
}
return mcp.NewToolResultText(output), nil
}
// generateASTSummary generates a summary of symbols in the file.
func (s *Server) generateASTSummary(ctx context.Context, path string, content []byte) string {
// Parse the file
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return "" // Silently skip AST if parsing fails
}
// Extract symbols
lang := protocol.DetectLanguage(path)
symbols := parser.ExtractSymbols(result.Tree, content, lang, path)
if len(symbols) == 0 {
return ""
}
var sb strings.Builder
// Get relative path
relPath := path
if absPath, err := filepath.Abs(path); err == nil {
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
relPath = rel
}
}
sb.WriteString(fmt.Sprintf("**%s** (%d lines, %s)\n\n", relPath, len(splitLines(string(content))), lang))
sb.WriteString("Symbols:\n")
for _, sym := range symbols {
kindStr := symbolKindIcon(sym.Kind)
sb.WriteString(fmt.Sprintf(" %s %s L%d\n", kindStr, sym.Name, sym.Location.Line))
}
return sb.String()
}
// symbolKindIcon returns an icon/prefix for a symbol kind.
func symbolKindIcon(kind protocol.SymbolKind) string {
switch kind {
case protocol.SymbolFunction:
return "func"
case protocol.SymbolMethod:
return "meth"
case protocol.SymbolClass:
return "class"
case protocol.SymbolStruct:
return "struct"
case protocol.SymbolInterface:
return "iface"
case protocol.SymbolVariable:
return "var"
case protocol.SymbolConstant:
return "const"
case protocol.SymbolType:
return "type"
case protocol.SymbolField:
return "field"
case protocol.SymbolProperty:
return "prop"
case protocol.SymbolModule:
return "mod"
case protocol.SymbolPackage:
return "pkg"
case protocol.SymbolEnum:
return "enum"
case protocol.SymbolTrait:
return "trait"
default:
return "sym"
}
}
// languageToExtensions maps language names to file extensions.
func languageToExtensions(language string) []string {
switch strings.ToLower(language) {
case "go":
return []string{".go"}
case "typescript":
return []string{".ts"}
case "javascript":
return []string{".js"}
case "python":
return []string{".py"}
case "c":
return []string{".c"}
case "cpp", "c++":
return []string{".cpp"}
case "html":
return []string{".html", ".htm"}
case "vue":
return []string{".vue"}
case "elixir":
return []string{".ex", ".exs"}
case "rust":
return []string{".rs"}
default:
return nil
}
}
+135
View File
@@ -0,0 +1,135 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/edit"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/mark3labs/mcp-go/mcp"
)
// unescapeNewlines converts literal \n, \t, \" sequences to actual characters.
// This handles cases where MCP clients send double-escaped JSON strings.
func unescapeNewlines(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\t", "\t")
s = strings.ReplaceAll(s, "\\\"", "\"")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}
// handleEditApply handles the edit_apply tool.
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.handleEdit(ctx, request)
}
// handleEdit performs an edit operation (always applies changes).
func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
file, err := request.RequireString("file")
if err != nil {
return mcp.NewToolResultError("file is required"), nil
}
operation, err := request.RequireString("operation")
if err != nil {
return mcp.NewToolResultError("operation is required"), nil
}
switch edit.EditOperation(operation) {
case edit.EditReplace, edit.EditInsertBefore, edit.EditInsertAfter, edit.EditDelete:
// valid
default:
return mcp.NewToolResultError(fmt.Sprintf(
"invalid operation %q: must be one of: replace, insert_before, insert_after, delete", operation,
)), nil
}
if !s.cfg.IsPathAllowed(file) {
return mcp.NewToolResultError("file is outside workspace root"), nil
}
newContent := request.GetString("new_content", "")
newContent = unescapeNewlines(newContent)
selectorName := request.GetString("selector_name", "")
astEdit := &edit.ASTEdit{
File: file,
Operation: edit.EditOperation(operation),
NewContent: newContent,
Selector: edit.ASTSelector{
Kind: request.GetString("selector_kind", ""),
Name: selectorName,
AtLine: request.GetInt("selector_line", 0),
Index: request.GetInt("selector_index", 0),
LineEnd: request.GetInt("selector_line_end", 0),
Text: request.GetString("selector_text", ""),
TextPattern: request.GetString("selector_pattern", ""),
},
}
result, err := s.editor.Apply(ctx, astEdit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("edit failed: %s", errors.SanitizeError(err))), nil
}
if !result.Success {
return mcp.NewToolResultError(result.Error), nil
}
// Determine response mode.
// compact_response is a deprecated alias for response="count".
respMode := request.GetString("response", "count")
if request.GetBool("compact_response", false) {
// Deprecated: use response=count
respMode = "count"
}
switch respMode {
case "none":
return mcp.NewToolResultText(""), nil
case "count":
// Compute +added/-removed line counts from the unified diff.
added, removed := countDiffLines(result.Diff)
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
case "diff":
var output strings.Builder
output.WriteString("Diff:\n```diff\n")
output.WriteString(result.Diff)
output.WriteString("```\n")
return mcp.NewToolResultText(output.String()), 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
}
}
// countDiffLines counts added (+) and removed (-) lines in a unified diff string.
func countDiffLines(diff string) (added, removed int) {
for _, line := range strings.Split(diff, "\n") {
if len(line) == 0 {
continue
}
switch line[0] {
case '+':
if !strings.HasPrefix(line, "+++") {
added++
}
case '-':
if !strings.HasPrefix(line, "---") {
removed++
}
}
}
return
}
+47
View File
@@ -0,0 +1,47 @@
package server
import "testing"
func TestUnescapeNewlines(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no escapes",
input: "func test() {\n\treturn true;\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash n",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash t",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal quotes",
input: `func test() {\n\treturn \"true\";\n}`,
expected: "func test() {\n\treturn \"true\";\n}",
},
{
name: "mixed content",
input: "line1\\nline2\\tindented\\nline3",
expected: "line1\nline2\tindented\nline3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := unescapeNewlines(tt.input)
if result != tt.expected {
t.Errorf("unescapeNewlines(%q) = %q, expected %q", tt.input, result, tt.expected)
}
})
}
}
+594
View File
@@ -0,0 +1,594 @@
// Package server implements the MCP server for file operations.
package server
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
xxhash "github.com/cespare/xxhash/v2"
"github.com/lukaszraczylo/mcp-filepuff/internal/cursor"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
"github.com/mark3labs/mcp-go/mcp"
)
// handleFileSearch handles the file_search tool.
func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
defer func() {
s.logger.Debug("file_search completed",
"duration_ms", time.Since(start).Milliseconds(),
)
}()
if s.searcher == nil {
return mcp.NewToolResultError("ripgrep (rg) is not available. Please install it: https://github.com/BurntSushi/ripgrep#installation"), nil
}
pattern, err := request.RequireString("pattern")
if err != nil {
return mcp.NewToolResultError("pattern is required"), nil
}
paths := request.GetStringSlice("paths", nil)
fileTypes := request.GetStringSlice("file_types", nil)
ignoreCase := request.GetBool("ignore_case", false)
regex := request.GetBool("regex", true)
contextLines := request.GetInt("context_lines", 2)
// Consult session prefs for max_results and cluster when not explicitly supplied.
prefs := s.sessionPrefs.Load()
var prefsMaxResults int
var prefsCluster *bool
if prefs != nil {
prefsMaxResults = prefs.DefaultMaxResults
prefsCluster = prefs.DefaultCluster
}
maxResults := effectiveInt(request, "max_results", prefsMaxResults, 0)
cluster := effectiveBool(request, "cluster", prefsCluster, false)
cursorStr := request.GetString("cursor", "")
// Compute query hash for cursor validation.
queryHash := cursor.HashParams(map[string]string{
"pattern": pattern,
"paths": strings.Join(paths, ","),
"file_types": strings.Join(fileTypes, ","),
"ignore_case": strconv.FormatBool(ignoreCase),
"regex": strconv.FormatBool(regex),
"context_lines": strconv.Itoa(contextLines),
})
// Resolve cursor offset.
offset := 0
if cursorStr != "" {
off, hash, decErr := cursor.Decode(cursorStr)
if decErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid cursor: %s", decErr)), nil
}
if hash != queryHash {
return mcp.NewToolResultError("cursor is for a different query, re-run without cursor"), nil
}
offset = off
}
// When paginating with a cursor, fetch all results (no rg-level cap) so we
// can apply the offset in-process. Without a cursor, let rg cap at maxResults.
rgMaxResults := maxResults
if offset > 0 {
rgMaxResults = 0 // fetch all, apply cap after skipping
}
req := &search.Request{
Pattern: pattern,
Paths: paths,
FileTypes: fileTypes,
IgnoreCase: ignoreCase,
Regex: regex,
ContextLines: contextLines,
MaxResults: rgMaxResults,
}
results, err := s.searcher.Search(ctx, req)
if err != nil {
s.logger.Warn("search error", "error", err)
return mcp.NewToolResultError(fmt.Sprintf("search error: %s", errors.SanitizeError(err))), nil
}
// Apply cursor offset.
if offset > 0 && offset < len(results.Results) {
results.Results = results.Results[offset:]
results.Truncated = false // will re-evaluate below
} else if offset > 0 {
results.Results = nil
results.Truncated = false
}
// Apply in-process max_results cap and compute cursor footer.
var cursorLine string
if maxResults > 0 && len(results.Results) > maxResults {
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)
}
s.logger.Info("search completed",
"pattern", pattern,
"results_count", len(results.Results),
"truncated", results.Truncated,
)
verbose := request.GetBool("verbose", false)
opts := search.FormatOptions{
Cluster: cluster,
CursorLine: cursorLine,
Verbose: verbose,
}
output := s.searcher.FormatResultsWithOptions(results, opts)
return mcp.NewToolResultText(output), nil
}
// effectiveInt returns the per-call value if the key is explicitly present in the
// request arguments, otherwise falls back to sessionDefault (if > 0), then builtIn.
func effectiveInt(request mcp.CallToolRequest, key string, sessionDefault, builtIn int) int {
if _, explicit := request.GetArguments()[key]; explicit {
return request.GetInt(key, builtIn)
}
if sessionDefault > 0 {
return sessionDefault
}
return builtIn
}
// effectiveBool returns the per-call value if the key is explicitly present in the
// request arguments, otherwise falls back to sessionDefault (if non-nil), then builtIn.
func effectiveBool(request mcp.CallToolRequest, key string, sessionDefault *bool, builtIn bool) bool {
if _, explicit := request.GetArguments()[key]; explicit {
return request.GetBool(key, builtIn)
}
if sessionDefault != nil {
return *sessionDefault
}
return builtIn
}
// handleFileRead handles the file_read tool.
func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
select {
case s.readSem <- struct{}{}:
defer func() { <-s.readSem }()
case <-ctx.Done():
return mcp.NewToolResultError("request cancelled"), nil
}
// Batch mode: paths[] takes precedence over path.
// NOTE: batch reads are always inlined — mixing dedup + resource_links is
// too complex and the savings are unclear for multi-file calls.
if paths := request.GetStringSlice("paths", nil); len(paths) > 0 {
var output strings.Builder
// Dedup: track etag -> first path that produced it.
seenEtag := make(map[string]string) // etag -> first path
for i, p := range paths {
if i > 0 {
output.WriteString("\n")
}
result, err := s.readOneFile(ctx, request, p)
if err != nil {
output.WriteString(fmt.Sprintf("--- %s ---\n[error: %s]\n", p, errors.SanitizeError(err)))
continue
}
// Extract etag from result footer for dedup check.
etag := extractEtag(result)
if etag != "" {
if firstPath, seen := seenEtag[etag]; seen {
// Duplicate content: emit pointer instead of full content.
output.WriteString(fmt.Sprintf("--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag))
continue
}
seenEtag[etag] = p
}
output.WriteString(fmt.Sprintf("--- %s ---\n%s", p, result))
}
return mcp.NewToolResultText(output.String()), nil
}
path := request.GetString("path", "")
if path == "" {
return mcp.NewToolResultError("path or paths is required"), nil
}
result, err := s.readOneFile(ctx, request, path)
if err != nil {
return mcp.NewToolResultError(errors.SanitizeError(err)), nil
}
// Resource-link threshold check: for single-file reads, if the result
// exceeds the configured threshold, return a ResourceLink instead of
// inlining the content. The client can fetch the resource on demand.
// Bypassed when:
// - force_inline=true
// - max_inline_bytes is set and result fits within it
// - threshold is 0 (disabled)
// - result is already small (skeleton/symbols_only/line-range paths
// produce small output; threshold is on result bytes, not file bytes)
// Determine resource-link threshold: session pref overrides cfg, per-call overrides session.
threshold := s.cfg.ResourceLinkThresholdBytes
if sp := s.sessionPrefs.Load(); sp != nil && sp.ResourceLinkThreshold > 0 {
threshold = sp.ResourceLinkThreshold
}
forceInline := request.GetBool("force_inline", false)
maxInlineBytes := request.GetInt("max_inline_bytes", 0)
if maxInlineBytes > 0 {
threshold = maxInlineBytes
}
if !forceInline && threshold > 0 && len(result) > threshold {
etag := extractEtag(result)
uri := buildReadResourceURI(path, etag)
lineCount := strings.Count(result, "\n")
desc := fmt.Sprintf("etag=%s, size=%d bytes, lines=%d", etag, len(result), lineCount)
mimeType := detectMIMEType(path)
link := mcp.NewResourceLink(uri, path, desc, mimeType)
return &mcp.CallToolResult{
Content: []mcp.Content{link},
}, nil
}
return mcp.NewToolResultText(result), nil
}
// buildReadResourceURI constructs the filepuff://read URI for a file + etag pair.
func buildReadResourceURI(path, etag string) string {
if etag == "" {
return "filepuff://read/" + path
}
return "filepuff://read/" + path + "?etag=" + etag
}
// detectMIMEType returns a best-effort MIME type for the given file path.
func detectMIMEType(path string) string {
ext := strings.ToLower(path)
switch {
case strings.HasSuffix(ext, ".go"):
return "text/x-go"
case strings.HasSuffix(ext, ".ts"), strings.HasSuffix(ext, ".tsx"):
return "text/typescript"
case strings.HasSuffix(ext, ".js"), strings.HasSuffix(ext, ".jsx"):
return "text/javascript"
case strings.HasSuffix(ext, ".py"):
return "text/x-python"
case strings.HasSuffix(ext, ".rs"):
return "text/x-rust"
case strings.HasSuffix(ext, ".md"):
return "text/markdown"
case strings.HasSuffix(ext, ".json"):
return "application/json"
case strings.HasSuffix(ext, ".yaml"), strings.HasSuffix(ext, ".yml"):
return "text/yaml"
case strings.HasSuffix(ext, ".toml"):
return "text/toml"
case strings.HasSuffix(ext, ".html"), strings.HasSuffix(ext, ".htm"):
return "text/html"
case strings.HasSuffix(ext, ".css"):
return "text/css"
case strings.HasSuffix(ext, ".sh"):
return "text/x-sh"
case strings.HasSuffix(ext, ".c"), strings.HasSuffix(ext, ".h"):
return "text/x-c"
case strings.HasSuffix(ext, ".cpp"), strings.HasSuffix(ext, ".cc"), strings.HasSuffix(ext, ".cxx"):
return "text/x-c++"
default:
return "text/plain"
}
}
// lineNumberOpts holds resolved line-numbering preferences for readOneFile.
type lineNumberOpts struct {
noLineNumbers bool
compactLineNums bool
lineInterval int
}
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
opts := lineNumberOpts{
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
compactLineNums: request.GetBool("compact_line_numbers", false),
}
// Apply session line_numbers pref when no explicit per-call override was supplied.
if sp := s.sessionPrefs.Load(); sp != nil && sp.LineNumbers != "" {
_, hasNoLN := request.GetArguments()["no_line_numbers"]
_, hasCompact := request.GetArguments()["compact_line_numbers"]
_, hasInterval := request.GetArguments()["line_number_interval"]
if !hasNoLN && !hasCompact && !hasInterval {
switch sp.LineNumbers {
case "none":
opts.noLineNumbers = true
opts.compactLineNums = false
case "compact":
opts.noLineNumbers = false
opts.compactLineNums = true
case "full":
opts.noLineNumbers = false
opts.compactLineNums = false
}
}
}
if opts.lineInterval == 0 {
opts.noLineNumbers = true
}
return opts
}
// applyStrip applies strip flags to the selected line range and returns the
// possibly-rewritten lines, new bounds, and a stripped-footer annotation.
func applyStrip(lines []string, lineStart, lineEnd int, stripFlags []parser.StripFlag, path string) (newLines []string, newStart, newEnd int, footer string) {
if len(stripFlags) == 0 {
return lines, lineStart, lineEnd, ""
}
selectedContent := strings.Join(lines[lineStart-1:lineEnd], "\n")
lang := protocol.DetectLanguage(path)
stripped := parser.StripContent(selectedContent, stripFlags, lang)
if len(stripped.Stripped) > 0 {
names := make([]string, len(stripped.Stripped))
for i, f := range stripped.Stripped {
names[i] = string(f)
}
footer = "[stripped: " + strings.Join(names, ", ") + "]\n"
}
newLines = splitLines(stripped.Content)
return newLines, 1, len(newLines), footer
}
// loadFileForRead performs workspace, stat, size, and read checks for a path.
func (s *Server) loadFileForRead(path string) ([]byte, error) {
if !s.cfg.IsPathAllowed(path) {
return nil, fmt.Errorf("path is outside workspace root")
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", path)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", path)
}
s.logger.Warn("file stat error", "path", path, "error", err)
return nil, fmt.Errorf("error accessing file")
}
if info.Size() > s.cfg.MaxFileSize {
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), s.cfg.MaxFileSize)
}
content, err := os.ReadFile(path)
if err != nil {
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", path)
}
s.logger.Warn("file read error", "path", path, "error", err)
return nil, fmt.Errorf("error reading file")
}
return content, nil
}
// readOneFile reads a single file applying all formatting options from the request.
func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, path string) (string, error) {
content, err := s.loadFileForRead(path)
if err != nil {
return "", err
}
// Feature 3: short etag — 8 hex chars (32-bit).
// Accept previous_etag by prefix match so old 16-char etags keep working.
fullHash := fmt.Sprintf("%016x", xxhash.Sum64(content))
etag := fullHash[:8]
if prev := request.GetString("previous_etag", ""); prev != "" {
// Match: exact 8-char match, old client sent full 16-char etag, or new client sent 8-char prefix of old.
if prev == etag || strings.HasPrefix(fullHash, prev) || strings.HasPrefix(prev, etag) {
return fmt.Sprintf("[unchanged, etag: %s]\n", etag), nil
}
}
// Parse request options
includeAST := request.GetBool("include_ast", false)
symbolsOnly := request.GetBool("symbols_only", false)
symbolName := request.GetString("symbol_name", "")
collapseBlank := request.GetBool("collapse_blank_lines", false)
maxLines := request.GetInt("max_lines", 0)
lnOpts := s.resolveLineNumberOpts(request)
// Feature 1: mode flag — "full" (default) | "skeleton" | "symbols_only".
// symbols_only mode is an alias for include_ast+symbols_only.
mode := request.GetString("mode", "full")
if mode == "symbols_only" {
symbolsOnly = true
includeAST = true
}
// Feature 2: strip — remove selected content classes before line-numbering.
stripRaw := request.GetStringSlice("strip", nil)
var stripFlags []parser.StripFlag
for _, sf := range stripRaw {
stripFlags = append(stripFlags, parser.StripFlag(sf))
}
if symbolsOnly && !includeAST {
return "", fmt.Errorf("symbols_only requires include_ast=true")
}
lines := splitLines(string(content))
lineStart := request.GetInt("line_start", 1)
lineEnd := request.GetInt("line_end", len(lines))
// Symbol-based line range: find the symbol and use its exact bounds.
if symbolName != "" {
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
start, end, found := s.resolveSymbolLines(ctx, path, content, symbolName, symbolKind)
if !found {
return "", fmt.Errorf("symbol %q not found in %s", symbolName, path)
}
lineStart = start
lineEnd = end
}
// Clamp to valid range.
if lineStart < 1 {
lineStart = 1
}
if lineEnd > len(lines) {
lineEnd = len(lines)
}
if lineStart > lineEnd {
lineStart = lineEnd
}
var output strings.Builder
// Feature 1: skeleton mode — replace function bodies with { ... }.
if mode == "skeleton" {
skText, _, skErr := parser.SkeletonFile(ctx, s.parser, path, content)
if skErr != nil {
// Fall back to full mode on parse error.
s.logger.Warn("skeleton mode failed, falling back to full", "path", path, "error", skErr)
} else {
output.WriteString(skText)
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
}
if includeAST {
if summary := s.generateASTSummary(ctx, path, content); summary != "" {
output.WriteString(summary)
if !symbolsOnly {
output.WriteString("\n---\n\n")
}
}
}
if symbolsOnly {
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
// Feature 2: apply strip AFTER line-range selection, BEFORE line numbering.
lines, lineStart, lineEnd, strippedFooter := applyStrip(lines, lineStart, lineEnd, stripFlags, path)
writeLines(&output, lines, lineStart, lineEnd, maxLines, lnOpts.noLineNumbers, lnOpts.lineInterval, collapseBlank, lnOpts.compactLineNums)
if strippedFooter != "" {
output.WriteString(strippedFooter)
}
fmt.Fprintf(&output, "[etag: %s]\n", etag)
return output.String(), nil
}
// resolveSymbolLines parses the AST and returns the line range of the named symbol.
// symbolKind optionally filters by kind (empty = any).
func (s *Server) resolveSymbolLines(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
result, err := s.parser.Parse(ctx, path, content)
if err != nil {
return
}
return parser.FindSymbolRange(result.Tree, content, path, symbolName, symbolKind)
}
// writeLines writes the selected line range into output, applying all formatting options.
// compactLineNums=true emits "12│" instead of " 12│ " (no padding, no trailing space).
func writeLines(output *strings.Builder, lines []string, lineStart, lineEnd, maxLines int, noLineNumbers bool, lineInterval int, collapseBlank bool, compactLineNums bool) {
effectiveEnd := lineEnd
truncatedCount := 0
if maxLines > 0 && (lineEnd-lineStart+1) > maxLines {
effectiveEnd = lineStart + maxLines - 1
truncatedCount = lineEnd - effectiveEnd
}
prevBlank := false
for i := lineStart - 1; i < effectiveEnd && i < len(lines); i++ {
line := lines[i]
isBlank := strings.TrimSpace(line) == ""
if collapseBlank && isBlank && prevBlank {
continue
}
prevBlank = isBlank
lineNum := i + 1
switch {
case noLineNumbers:
output.WriteString(line + "\n")
case compactLineNums:
// Feature 4: compact prefix — "12│content"
if lineInterval <= 1 || lineNum%lineInterval == 0 || i == lineStart-1 || i == effectiveEnd-1 {
fmt.Fprintf(output, "%d│%s\n", lineNum, line)
} else {
fmt.Fprintf(output, "│%s\n", line)
}
case lineInterval <= 1 || lineNum%lineInterval == 0 || i == lineStart-1 || i == effectiveEnd-1:
fmt.Fprintf(output, "%4d│ %s\n", lineNum, line)
default:
fmt.Fprintf(output, " │ %s\n", line)
}
}
if truncatedCount > 0 {
fmt.Fprintf(output, "\n[... %d more lines omitted. Use line_start/line_end or increase max_lines to see more]\n", truncatedCount)
}
}
// extractEtag extracts the etag value from a readOneFile result string.
// Returns empty string if not found.
func extractEtag(result string) string {
// Look for "[etag: XXXXXXXX]" at end of result.
const prefix = "[etag: "
idx := strings.LastIndex(result, prefix)
if idx < 0 {
return ""
}
rest := result[idx+len(prefix):]
close := strings.Index(rest, "]")
if close < 0 {
return ""
}
return rest[:close]
}
// splitLines splits a string into lines.
// For large files (> 1MB), uses bufio.Scanner which is more memory efficient.
// For smaller files, uses simple string split which is faster.
func splitLines(s string) []string {
const largeSizeThreshold = 1024 * 1024 // 1MB
if len(s) > largeSizeThreshold {
scanner := bufio.NewScanner(strings.NewReader(s))
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return strings.Split(s, "\n")
}
if len(s) > 0 && s[len(s)-1] == '\n' {
lines = append(lines, "")
}
return lines
}
return strings.Split(s, "\n")
}
+909
View File
@@ -0,0 +1,909 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// newTestServer creates a minimal server pointing at tmpDir.
func newTestServer(t *testing.T, tmpDir string) *Server {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 10 * 1024 * 1024, // 10 MB — required for file reads to succeed
MaxParseSize: 10 * 1024 * 1024,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
// callRead calls handleFileRead with the given args map and returns the text content.
func callRead(t *testing.T, srv *Server, args map[string]interface{}) string {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := srv.handleFileRead(context.Background(), req)
if err != nil {
t.Fatalf("handleFileRead error: %v", err)
}
if result == nil {
t.Fatal("handleFileRead returned nil")
}
if len(result.Content) == 0 {
t.Fatal("handleFileRead returned empty content")
}
tc, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("handleFileRead did not return TextContent")
}
return tc.Text
}
// callReadResult calls handleFileRead and returns the raw CallToolResult (not just text).
func callReadResult(t *testing.T, srv *Server, args map[string]interface{}) *mcp.CallToolResult {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := srv.handleFileRead(context.Background(), req)
if err != nil {
t.Fatalf("handleFileRead error: %v", err)
}
if result == nil {
t.Fatal("handleFileRead returned nil")
}
return result
}
// newTestServerWithThreshold creates a server with a custom ResourceLinkThresholdBytes.
func newTestServerWithThreshold(t *testing.T, tmpDir string, thresholdBytes int) *Server {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 10 * 1024 * 1024,
MaxParseSize: 10 * 1024 * 1024,
ResourceLinkThresholdBytes: thresholdBytes,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
// writeFile writes content to a file in tmpDir and returns its absolute path.
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
t.Fatalf("WriteFile(%s): %v", name, err)
}
return p
}
// ---- Feature 1: skeleton mode ----
func TestSkeletonModeGo(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
// Hello says hello
func Hello() {
println("Hello, World!")
println("more body")
}
func Add(a, b int) int {
return a + b
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"mode": "skeleton",
})
// Should contain function signatures
if !strings.Contains(out, "func Hello()") {
t.Errorf("skeleton output missing Hello signature, got:\n%s", out)
}
if !strings.Contains(out, "func Add(") {
t.Errorf("skeleton output missing Add signature, got:\n%s", out)
}
// Should NOT contain body contents
if strings.Contains(out, `println("more body")`) {
t.Errorf("skeleton output should not contain body contents, got:\n%s", out)
}
// Should contain placeholder
if !strings.Contains(out, "{ ... }") {
t.Errorf("skeleton output missing { ... } placeholder, got:\n%s", out)
}
// Should contain etag footer
if !strings.Contains(out, "[etag:") {
t.Errorf("skeleton output missing etag footer, got:\n%s", out)
}
}
func TestSkeletonModeFullFlagAlias(t *testing.T) {
// mode="full" should behave identically to not specifying mode
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc F() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
outFull := callRead(t, srv, map[string]interface{}{"path": f, "mode": "full"})
outDefault := callRead(t, srv, map[string]interface{}{"path": f})
// Both should have same content (etag will be same, line content same)
if outFull != outDefault {
t.Errorf("mode=full differs from default\nfull: %q\ndefault: %q", outFull, outDefault)
}
}
func TestSkeletonModeSymbolsOnlyAlias(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc F() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
// mode=symbols_only should return symbols summary (needs include_ast implicitly)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"mode": "symbols_only",
})
// Should contain etag but NOT the function body
if !strings.Contains(out, "[etag:") {
t.Errorf("symbols_only output missing etag, got:\n%s", out)
}
if strings.Contains(out, "println") {
t.Errorf("symbols_only should not contain body, got:\n%s", out)
}
}
func TestSkeletonModeTypeScript(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
tsSrc := `// a function
function greet(name: string): string {
return "Hello " + name;
}
class Greeter {
greet(name: string) {
return "hi " + name;
}
}
`
f := writeFile(t, tmpDir, "test.ts", tsSrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "function greet") {
t.Errorf("TS skeleton missing function signature, got:\n%s", out)
}
if !strings.Contains(out, "{ ... }") {
t.Errorf("TS skeleton missing placeholder, got:\n%s", out)
}
if strings.Contains(out, `"Hello " + name`) {
t.Errorf("TS skeleton should not contain body, got:\n%s", out)
}
}
func TestSkeletonModePython(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
pySrc := `def greet(name):
print("Hello " + name)
print("extra line")
class Foo:
def bar(self):
return 42
`
f := writeFile(t, tmpDir, "test.py", pySrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "def greet") {
t.Errorf("Python skeleton missing greet signature, got:\n%s", out)
}
if strings.Contains(out, "extra line") {
t.Errorf("Python skeleton should not contain body, got:\n%s", out)
}
// Python uses "..." as placeholder
if !strings.Contains(out, "...") {
t.Errorf("Python skeleton missing ... placeholder, got:\n%s", out)
}
}
func TestSkeletonModeRust(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
rsSrc := `fn add(a: i32, b: i32) -> i32 {
let result = a + b;
result
}
struct Foo {
x: i32,
}
`
f := writeFile(t, tmpDir, "test.rs", rsSrc)
out := callRead(t, srv, map[string]interface{}{"path": f, "mode": "skeleton"})
if !strings.Contains(out, "fn add(") {
t.Errorf("Rust skeleton missing fn signature, got:\n%s", out)
}
if !strings.Contains(out, "{ ... }") {
t.Errorf("Rust skeleton missing placeholder, got:\n%s", out)
}
if strings.Contains(out, "let result") {
t.Errorf("Rust skeleton should not contain body, got:\n%s", out)
}
}
// ---- Feature 2: strip flag ----
func TestStripImportsGo(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("hello")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, `"fmt"`) {
t.Errorf("strip=imports should remove import block, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=imports should keep function, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: imports]") {
t.Errorf("strip footer missing, got:\n%s", out)
}
}
func TestStripLicenseGoBlockComment(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `/* Copyright 2024 Acme Corp. All rights reserved.
License: MIT
*/
package main
func main() {}
`
f := writeFile(t, tmpDir, "main.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"license"},
})
if strings.Contains(out, "Copyright") {
t.Errorf("strip=license should remove license comment, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=license should keep code, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: license]") {
t.Errorf("license strip footer missing, got:\n%s", out)
}
}
func TestStripBlockCommentsGo(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
/* This is a block comment
spanning multiple lines */
func main() {
/* inline block */
println("hi")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"block_comments"},
})
if strings.Contains(out, "This is a block comment") {
t.Errorf("strip=block_comments should remove block comments, got:\n%s", out)
}
if strings.Contains(out, "inline block") {
t.Errorf("strip=block_comments should remove inline block comment, got:\n%s", out)
}
if !strings.Contains(out, "func main") {
t.Errorf("strip=block_comments should keep code, got:\n%s", out)
}
if !strings.Contains(out, "[stripped: block_comments]") {
t.Errorf("block_comments strip footer missing, got:\n%s", out)
}
}
func TestStripMultipleFlags(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `/* Copyright 2024. License: MIT */
package main
import "fmt"
func main() {
fmt.Println("hello")
}
`
f := writeFile(t, tmpDir, "main.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"license", "imports"},
})
if strings.Contains(out, "Copyright") {
t.Errorf("license not stripped, got:\n%s", out)
}
if strings.Contains(out, `"fmt"`) {
t.Errorf("imports not stripped, got:\n%s", out)
}
if !strings.Contains(out, "[stripped:") {
t.Errorf("strip footer missing, got:\n%s", out)
}
}
func TestStripNoRemovalProducesNoFooter(t *testing.T) {
// A file with no imports: strip=imports should not add footer
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc main() {}\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, "[stripped:") {
t.Errorf("should not have stripped footer when nothing removed, got:\n%s", out)
}
}
func TestStripImportsPython(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
pySrc := `import os
from sys import argv
def main():
print(argv[0])
`
f := writeFile(t, tmpDir, "test.py", pySrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"strip": []interface{}{"imports"},
})
if strings.Contains(out, "import os") {
t.Errorf("Python imports not stripped, got:\n%s", out)
}
if strings.Contains(out, "from sys") {
t.Errorf("Python from-import not stripped, got:\n%s", out)
}
if !strings.Contains(out, "def main") {
t.Errorf("Python function missing after strip, got:\n%s", out)
}
}
// ---- Feature 3: short etag (8 hex chars) ----
func TestEtagIs8Chars(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
out := callRead(t, srv, map[string]interface{}{"path": f})
// Find "[etag: XXXXXXXX]"
idx := strings.Index(out, "[etag: ")
if idx < 0 {
t.Fatalf("no etag in output: %q", out)
}
rest := out[idx+7:]
end := strings.Index(rest, "]")
if end < 0 {
t.Fatalf("malformed etag in output: %q", out)
}
etagVal := rest[:end]
if len(etagVal) != 8 {
t.Errorf("etag should be 8 hex chars, got %d chars: %q", len(etagVal), etagVal)
}
// Validate hex
for _, c := range etagVal {
isDigit := c >= '0' && c <= '9'
isHexLower := c >= 'a' && c <= 'f'
if !isDigit && !isHexLower {
t.Errorf("etag contains non-hex char %q in %q", c, etagVal)
}
}
}
func TestEtagPreviousEtagShortCircuit(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
// First read: get the etag
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
etag := out1[idx+7 : idx+7+8]
// Second read with same etag: should short-circuit
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": etag,
})
if !strings.Contains(out2, "[unchanged, etag:") {
t.Errorf("expected [unchanged, etag:] for same etag, got: %q", out2)
}
}
func TestEtagOldLongEtagStillWorks(t *testing.T) {
// Simulate old client sending 16-char etag: should still short-circuit via prefix match.
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
// Get the 8-char etag
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
shortEtag := out1[idx+7 : idx+7+8]
// Construct a fake 16-char etag that starts with the short one
fakeOldEtag := shortEtag + "00000000"
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": fakeOldEtag,
})
if !strings.Contains(out2, "[unchanged, etag:") {
t.Errorf("old 16-char etag should still short-circuit, got: %q", out2)
}
}
func TestEtagDifferentFileChanges(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.go", "package main\n")
out1 := callRead(t, srv, map[string]interface{}{"path": f})
idx := strings.Index(out1, "[etag: ")
etag1 := out1[idx+7 : idx+7+8]
// Modify the file
if err := os.WriteFile(f, []byte("package main\n// changed\n"), 0600); err != nil {
t.Fatal(err)
}
// Read again with old etag: should NOT short-circuit
out2 := callRead(t, srv, map[string]interface{}{
"path": f,
"previous_etag": etag1,
})
if strings.Contains(out2, "[unchanged") {
t.Errorf("modified file should not return unchanged, got: %q", out2)
}
}
// ---- Feature 4: compact_line_numbers ----
func TestCompactLineNumbers(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
content := "line one\nline two\nline three\n"
f := writeFile(t, tmpDir, "test.txt", content)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
})
// Should have "1│" not " 1│ "
if !strings.Contains(out, "1│line one") {
t.Errorf("compact prefix not found, got:\n%s", out)
}
// Should NOT have padded format
if strings.Contains(out, " 1│ line one") {
t.Errorf("compact should not have padded prefix, got:\n%s", out)
}
}
func TestCompactLineNumbersOffByDefault(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})
// Should have padded format
if !strings.Contains(out, " 1│ line one") {
t.Errorf("default should have padded format, got:\n%s", out)
}
}
func TestCompactLineNumbersWithInterval(t *testing.T) {
// compact_line_numbers + line_number_interval should work together
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
var sb strings.Builder
for i := 1; i <= 10; i++ {
sb.WriteString("line\n")
}
f := writeFile(t, tmpDir, "test.txt", sb.String())
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
"line_number_interval": 5,
})
// Line 5 should have number prefix
if !strings.Contains(out, "5│line") {
t.Errorf("compact+interval: line 5 should have number, got:\n%s", out)
}
// Line 1 (first) should have number
if !strings.Contains(out, "1│line") {
t.Errorf("compact+interval: line 1 should have number, got:\n%s", out)
}
// Line 10 (last) should have number
if !strings.Contains(out, "10│line") {
t.Errorf("compact+interval: line 10 should have number, got:\n%s", out)
}
// Non-interval line should have bare │ prefix (no number)
if !strings.Contains(out, "│line") {
t.Errorf("compact+interval: non-interval lines should have bare │, got:\n%s", out)
}
}
func TestCompactLineNumbersWithNoLineNumbers(t *testing.T) {
// compact_line_numbers + no_line_numbers: no_line_numbers wins
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
f := writeFile(t, tmpDir, "test.txt", "line one\n")
out := callRead(t, srv, map[string]interface{}{
"path": f,
"compact_line_numbers": true,
"no_line_numbers": true,
})
// Should have NO prefix at all
if strings.Contains(out, "│") {
t.Errorf("no_line_numbers should suppress all prefixes, got:\n%s", out)
}
}
// ---- Backward compatibility: existing behavior unchanged ----
func TestDefaultBehaviorUnchanged(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := `package main
func Hello() {
println("hello")
}
`
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{"path": f})
// All lines present
if !strings.Contains(out, `println("hello")`) {
t.Errorf("full mode missing body, got:\n%s", out)
}
// Padded line numbers
if !strings.Contains(out, " 1│ package main") {
t.Errorf("default should have padded line numbers, got:\n%s", out)
}
// etag present
if !strings.Contains(out, "[etag:") {
t.Errorf("missing etag footer, got:\n%s", out)
}
}
func TestSymbolsOnlyFlagStillWorks(t *testing.T) {
// Old symbols_only=true + include_ast=true should still work
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
goSrc := "package main\nfunc Hello() { println(1) }\n"
f := writeFile(t, tmpDir, "test.go", goSrc)
out := callRead(t, srv, map[string]interface{}{
"path": f,
"include_ast": true,
"symbols_only": true,
})
if strings.Contains(out, "println") {
t.Errorf("symbols_only should suppress body, got:\n%s", out)
}
if !strings.Contains(out, "[etag:") {
t.Errorf("symbols_only missing etag, got:\n%s", out)
}
}
// ---- Feature: resource_link for big reads ----
func TestResourceLinkThresholdTrip(t *testing.T) {
// When result bytes > threshold, handleFileRead returns a ResourceLink content block.
tmpDir := t.TempDir()
// Low threshold (10 bytes) guarantees even a tiny file trips it.
srv := newTestServerWithThreshold(t, tmpDir, 10)
f := writeFile(t, tmpDir, "big.txt", strings.Repeat("x", 200))
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink content, got %T", result.Content[0])
}
if !strings.HasPrefix(link.URI, "filepuff://read/") {
t.Errorf("ResourceLink URI should start with filepuff://read/, got: %q", link.URI)
}
if link.Name != f {
t.Errorf("ResourceLink Name should be file path %q, got %q", f, link.Name)
}
if !strings.Contains(link.Description, "etag=") {
t.Errorf("ResourceLink Description should contain etag=, got %q", link.Description)
}
if !strings.Contains(link.Description, "size=") {
t.Errorf("ResourceLink Description should contain size=, got %q", link.Description)
}
if !strings.Contains(link.Description, "lines=") {
t.Errorf("ResourceLink Description should contain lines=, got %q", link.Description)
}
}
func TestResourceLinkForceInlineBypass(t *testing.T) {
// force_inline=true must always return TextContent regardless of threshold.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1) // threshold = 1 byte, always trips
f := writeFile(t, tmpDir, "test.txt", "hello world")
result := callReadResult(t, srv, map[string]interface{}{
"path": f,
"force_inline": true,
})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
tc, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("force_inline=true should return TextContent, got %T", result.Content[0])
}
if !strings.Contains(tc.Text, "hello world") {
t.Errorf("force_inline result should contain file content, got: %q", tc.Text)
}
}
func TestResourceLinkMaxInlineBytesOverride(t *testing.T) {
// max_inline_bytes overrides server threshold per-call.
tmpDir := t.TempDir()
// Server threshold = 1 byte (always trips), but max_inline_bytes allows bigger.
srv := newTestServerWithThreshold(t, tmpDir, 1)
content := strings.Repeat("a", 50) // 50 bytes
f := writeFile(t, tmpDir, "test.txt", content)
// max_inline_bytes=100 > 50 bytes result → should inline
result := callReadResult(t, srv, map[string]interface{}{
"path": f,
"max_inline_bytes": 100,
})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("max_inline_bytes=100 with 50-byte file should return TextContent, got %T", result.Content[0])
}
}
func TestResourceLinkStaleEtagRejection(t *testing.T) {
// handleReadResource should reject fetch when file has changed since link was issued.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1) // always trips
f := writeFile(t, tmpDir, "stale.txt", "original content")
// Get a ResourceLink — captures etag of "original content"
result := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink, got %T", result.Content[0])
}
// URI contains ?etag=<hash-of-original>
if !strings.Contains(link.URI, "?etag=") {
t.Fatalf("expected etag in URI, got %q", link.URI)
}
// Overwrite the file with new content.
if err := os.WriteFile(f, []byte("modified content — different"), 0600); err != nil {
t.Fatal(err)
}
// Fetch the resource using the stale URI — should error.
req := mcp.ReadResourceRequest{}
req.Params.URI = link.URI
_, err := srv.handleReadResource(req)
if err == nil {
t.Fatal("expected error for stale etag, got nil")
}
if !strings.Contains(err.Error(), "file changed") {
t.Errorf("error should mention 'file changed', got: %v", err)
}
}
func TestResourceLinkBelowThresholdInlines(t *testing.T) {
// When result is small (below threshold), always inline regardless of threshold setting.
tmpDir := t.TempDir()
// Large threshold — small file should be inlined.
srv := newTestServerWithThreshold(t, tmpDir, 64*1024)
f := writeFile(t, tmpDir, "small.txt", "tiny")
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content, got none")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("small file should return TextContent, got %T", result.Content[0])
}
}
func TestResourceLinkThresholdZeroDisabled(t *testing.T) {
// threshold=0 disables the feature entirely — always inline.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 0)
f := writeFile(t, tmpDir, "test.txt", strings.Repeat("z", 10000))
result := callReadResult(t, srv, map[string]interface{}{"path": f})
if len(result.Content) == 0 {
t.Fatal("expected content")
}
_, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatalf("threshold=0 should always inline, got %T", result.Content[0])
}
}
func TestHandleReadResource_ValidFetch(t *testing.T) {
// handleReadResource fetches file content when etag matches.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1)
f := writeFile(t, tmpDir, "fetch.txt", "fetch me please")
// Trigger a ResourceLink to get a valid URI with correct etag.
toolResult := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := toolResult.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink, got %T", toolResult.Content[0])
}
req := mcp.ReadResourceRequest{}
req.Params.URI = link.URI
contents, err := srv.handleReadResource(req)
if err != nil {
t.Fatalf("handleReadResource error: %v", err)
}
if len(contents) == 0 {
t.Fatal("expected resource contents, got none")
}
tc, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatalf("expected TextResourceContents, got %T", contents[0])
}
if !strings.Contains(tc.Text, "fetch me please") {
t.Errorf("resource contents should include file content, got: %q", tc.Text)
}
}
func TestResourceLinkMIMEType(t *testing.T) {
// Verify MIME types for common extensions.
tmpDir := t.TempDir()
srv := newTestServerWithThreshold(t, tmpDir, 1)
cases := []struct {
name string
content string
wantMIME string
}{
{"test.go", "package main\n", "text/x-go"},
{"test.py", "# py\n", "text/x-python"},
{"test.ts", "// ts\n", "text/typescript"},
{"test.json", "{}\n", "application/json"},
{"test.md", "# hi\n", "text/markdown"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := writeFile(t, tmpDir, c.name, c.content)
result := callReadResult(t, srv, map[string]interface{}{"path": f})
link, ok := result.Content[0].(mcp.ResourceLink)
if !ok {
t.Fatalf("expected ResourceLink for %s, got %T", c.name, result.Content[0])
}
if link.MIMEType != c.wantMIME {
t.Errorf("%s: MIMEType = %q, want %q", c.name, link.MIMEType, c.wantMIME)
}
})
}
}
+280
View File
@@ -0,0 +1,280 @@
// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"os"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/mark3labs/mcp-go/mcp"
)
// handleLSPQuery is the unified dispatcher for all LSP operations.
// action must be one of: "hover", "definition", "references".
func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
action, err := request.RequireString("action")
if err != nil {
return mcp.NewToolResultError("action is required (hover | definition | references)"), nil
}
file, err := request.RequireString("file")
if err != nil {
return mcp.NewToolResultError("file is required"), nil
}
line := request.GetInt("line", 0)
if line <= 0 {
return mcp.NewToolResultError("line must be positive"), nil
}
col := request.GetInt("column", 0)
if col <= 0 {
return mcp.NewToolResultError("column must be positive"), nil
}
if !s.cfg.IsPathAllowed(file) {
return mcp.NewToolResultError("file is outside workspace root"), nil
}
verbose := request.GetBool("verbose", false)
switch action {
case "hover":
if _, ok := request.GetArguments()["include_declaration"]; ok {
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
}
if _, ok := request.GetArguments()["compact"]; ok {
return mcp.NewToolResultError("compact is only valid for action=references"), nil
}
return s.lspHover(ctx, file, line, col, verbose)
case "definition":
if _, ok := request.GetArguments()["include_declaration"]; ok {
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
}
if _, ok := request.GetArguments()["compact"]; ok {
return mcp.NewToolResultError("compact is only valid for action=references"), nil
}
return s.lspDefinition(ctx, file, line, col, verbose)
case "references":
includeDecl := request.GetBool("include_declaration", true)
// compact: explicit call-time > session compact_refs pref > false
var prefsCompact *bool
if sp := s.sessionPrefs.Load(); sp != nil {
prefsCompact = sp.CompactRefs
}
compact := effectiveBool(request, "compact", prefsCompact, false)
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
default:
return mcp.NewToolResultError(fmt.Sprintf("unknown action %q: must be hover | definition | references", action)), nil
}
}
// lspHover performs hover (symbol info) for the given position.
func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
hover, err := s.lspManager.Hover(ctx, file, line, col)
if err != nil {
return s.handleSymbolAtFallback(ctx, file, line, col, verbose)
}
if hover == nil {
return mcp.NewToolResultText("No symbol information available at this position."), nil
}
var output strings.Builder
if verbose {
output.WriteString("**Symbol Information**\n\n")
}
output.WriteString(hover.Contents.Value)
return mcp.NewToolResultText(output.String()), nil
}
// handleSymbolAtFallback provides AST-based symbol info when LSP is unavailable.
func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
content, err := os.ReadFile(file)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err))), nil
}
result, err := s.parser.Parse(ctx, file, content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err))), nil
}
node := parser.FindNodeAtPosition(result.Tree, line, col)
if node == nil {
return mcp.NewToolResultText("No symbol at this position."), nil
}
var output strings.Builder
if verbose {
output.WriteString("**Symbol Information** (AST fallback)\n\n")
}
output.WriteString(fmt.Sprintf("Node type: `%s`\n", node.Type()))
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content)))
return mcp.NewToolResultText(output.String()), nil
}
// lspDefinition finds the definition of the symbol at the given position.
func (s *Server) lspDefinition(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
locations, err := s.lspManager.Definition(ctx, file, line, col)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("definition lookup failed: %s", errors.SanitizeError(err))), nil
}
if len(locations) == 0 {
return mcp.NewToolResultText("No definition found."), nil
}
var output strings.Builder
if verbose {
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations)))
}
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
output.WriteString(fmt.Sprintf("**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1))
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
if preview != "" {
output.WriteString("```\n")
output.WriteString(preview)
output.WriteString("```\n")
}
output.WriteString("\n")
}
return mcp.NewToolResultText(output.String()), nil
}
// lspReferences finds all references to the symbol at the given position.
func (s *Server) lspReferences(ctx context.Context, file string, line, col int, includeDecl, compact, verbose bool) (*mcp.CallToolResult, error) {
locations, err := s.lspManager.References(ctx, file, line, col, includeDecl)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("references lookup failed: %s", errors.SanitizeError(err))), nil
}
if len(locations) == 0 {
return mcp.NewToolResultText("No references found."), nil
}
// Group by file, preserving encounter order.
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
if _, seen := fileGroups[filePath]; !seen {
fileOrder = append(fileOrder, filePath)
}
fileGroups[filePath] = append(fileGroups[filePath], loc)
}
return mcp.NewToolResultText(formatReferences(fileGroups, fileOrder, len(locations), compact, verbose)), nil
}
// formatReferences formats grouped reference locations as a string.
// compact=false: verbose multi-line format with L{line}:{col} per entry.
// compact=true: one line per file — file:[line:col, ...] (N), with same-line
// columns collapsed to line:{col1,col2,...}.
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
var output strings.Builder
if verbose {
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total))
}
for _, filePath := range fileOrder {
locs := fileGroups[filePath]
if compact {
output.WriteString(formatReferencesCompact(filePath, locs))
} else {
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
for _, loc := range locs {
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1))
}
output.WriteString("\n")
}
}
return output.String()
}
// formatReferencesCompact formats one file's references as a single compact line.
// Same-line references are collapsed: 12:{5,8} instead of 12:5, 12:8.
func formatReferencesCompact(filePath string, locs []lsp.Location) string {
// Build ordered line->col map preserving encounter order per line.
type lineEntry struct {
lineNum int
cols []int
}
lineMap := make(map[int]*lineEntry)
lineOrder := make([]int, 0, len(locs))
for _, loc := range locs {
ln := loc.Range.Start.Line + 1
col := loc.Range.Start.Character + 1
if e, ok := lineMap[ln]; ok {
e.cols = append(e.cols, col)
} else {
lineMap[ln] = &lineEntry{lineNum: ln, cols: []int{col}}
lineOrder = append(lineOrder, ln)
}
}
// Build the bracket contents.
parts := make([]string, 0, len(lineOrder))
for _, ln := range lineOrder {
e := lineMap[ln]
if len(e.cols) == 1 {
parts = append(parts, fmt.Sprintf("%d:%d", ln, e.cols[0]))
} else {
colStrs := make([]string, len(e.cols))
for i, c := range e.cols {
colStrs[i] = fmt.Sprintf("%d", c)
}
parts = append(parts, fmt.Sprintf("%d:{%s}", ln, strings.Join(colStrs, ",")))
}
}
return fmt.Sprintf("%s:[%s] (%d)\n", filePath, strings.Join(parts, ", "), len(locs))
}
// readFilePreview reads a few lines from a file around the given line.
// It validates that the file path is within the allowed workspace before reading.
func (s *Server) readFilePreview(file string, line, contextLines int) string {
if !s.cfg.IsPathAllowed(file) {
s.logger.Warn("readFilePreview: path not allowed", "path", file)
return ""
}
content, err := os.ReadFile(file)
if err != nil {
return ""
}
lines := splitLines(string(content))
startLine := max(1, line-contextLines)
endLine := min(line+contextLines, len(lines))
var preview strings.Builder
for i := startLine - 1; i < endLine && i < len(lines); i++ {
lineText := lines[i]
if len(lineText) > PreviewLineMaxLength {
lineText = lineText[:PreviewLineMaxLength] + "..."
}
prefix := " "
if i+1 == line {
prefix = "> "
}
preview.WriteString(fmt.Sprintf("%s%4d: %s\n", prefix, i+1, lineText))
}
return preview.String()
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
)
// makeLocation builds an lsp.Location with a file:// URI.
func makeLocation(file string, line, col int) lsp.Location {
return lsp.Location{
URI: "file://" + file,
Range: lsp.Range{
Start: lsp.Position{Line: line - 1, Character: col - 1},
End: lsp.Position{Line: line - 1, Character: col - 1},
},
}
}
// groupLocations is a helper that mirrors the grouping logic in lspReferences.
func groupLocations(locations []lsp.Location) (map[string][]lsp.Location, []string) {
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
if _, seen := fileGroups[filePath]; !seen {
fileOrder = append(fileOrder, filePath)
}
fileGroups[filePath] = append(fileGroups[filePath], loc)
}
return fileGroups, fileOrder
}
// TestFormatReferencesVerbose verifies the default (non-compact) format is unchanged.
func TestFormatReferencesVerbose(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 13, 8),
makeLocation("/a/bar.go", 15, 1),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), false, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
if !strings.Contains(out, "**") {
t.Errorf("verbose format should use **file** markers, got:\n%s", out)
}
if !strings.Contains(out, "L12:5") {
t.Errorf("missing L12:5 in verbose output, got:\n%s", out)
}
if !strings.Contains(out, "L13:8") {
t.Errorf("missing L13:8 in verbose output, got:\n%s", out)
}
if !strings.Contains(out, "L15:1") {
t.Errorf("missing L15:1 in verbose output, got:\n%s", out)
}
}
// TestFormatReferencesCompactBasic verifies compact output for distinct lines.
func TestFormatReferencesCompactBasic(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 13, 8),
makeLocation("/a/foo.go", 15, 12),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
// Should contain "foo.go:[12:5, 13:8, 15:12] (3)"
if !strings.Contains(out, "12:5") {
t.Errorf("missing 12:5 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "13:8") {
t.Errorf("missing 13:8 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "15:12") {
t.Errorf("missing 15:12 in compact output, got:\n%s", out)
}
if !strings.Contains(out, "(3)") {
t.Errorf("missing (3) count, got:\n%s", out)
}
// Compact format must NOT use ** markers
if strings.Contains(out, "**") {
t.Errorf("compact format must not use ** markers, got:\n%s", out)
}
// Must not have L prefix
if strings.Contains(out, "L12") {
t.Errorf("compact format must not have L prefix, got:\n%s", out)
}
}
// TestFormatReferencesCompactSameLineCollapse verifies same-line columns collapse.
func TestFormatReferencesCompactSameLineCollapse(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 12, 5),
makeLocation("/a/foo.go", 12, 8),
makeLocation("/a/foo.go", 15, 3),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
// Line 12 has two refs → should be collapsed: 12:{5,8}
if !strings.Contains(out, "12:{5,8}") {
t.Errorf("same-line refs should collapse to 12:{5,8}, got:\n%s", out)
}
// Line 15 is single → 15:3
if !strings.Contains(out, "15:3") {
t.Errorf("single ref on line 15 should be 15:3, got:\n%s", out)
}
}
// TestFormatReferencesCompactMultiFile verifies compact output across multiple files.
func TestFormatReferencesCompactMultiFile(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/foo.go", 5, 1),
makeLocation("/b/bar.go", 10, 3),
makeLocation("/b/bar.go", 10, 7),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, true)
if !strings.Contains(out, "Found 3 reference(s):") {
t.Errorf("missing header, got:\n%s", out)
}
// foo.go: one ref
if !strings.Contains(out, "5:1") {
t.Errorf("missing 5:1 for foo.go, got:\n%s", out)
}
// bar.go: two refs on same line → collapsed
if !strings.Contains(out, "10:{3,7}") {
t.Errorf("missing 10:{3,7} collapse for bar.go, got:\n%s", out)
}
}
// TestFormatReferencesCompactSingleRef verifies single-reference compact output.
func TestFormatReferencesCompactSingleRef(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/only.go", 7, 2),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
if !strings.Contains(out, "7:2") {
t.Errorf("missing 7:2, got:\n%s", out)
}
if !strings.Contains(out, "(1)") {
t.Errorf("missing (1), got:\n%s", out)
}
}
// TestFormatReferencesCompactNoLPrefix verifies the L prefix is absent in compact mode.
func TestFormatReferencesCompactNoLPrefix(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/x.go", 3, 4),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), true, false)
if strings.Contains(out, "L3") {
t.Errorf("compact output must not contain L prefix, got:\n%s", out)
}
}
// TestFormatReferencesVerboseNoChange verifies compact=false preserves old L-prefix format.
func TestFormatReferencesVerbosePreservesLPrefix(t *testing.T) {
locs := []lsp.Location{
makeLocation("/a/x.go", 3, 4),
}
groups, order := groupLocations(locs)
out := formatReferences(groups, order, len(locs), false, true)
if !strings.Contains(out, "L3:4") {
t.Errorf("verbose output must contain L3:4, got:\n%s", out)
}
}
+183
View File
@@ -0,0 +1,183 @@
// Package server implements the MCP server for file operations.
package server
// helpFileRead is the full flag documentation and examples for the file_read tool,
// served at filepuff://help/file_read.
const helpFileRead = "# file_read — flags and examples\n\n" +
"## Token-saving features\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `previous_etag` | Skip re-reading unchanged files. Returns `[unchanged, etag: ...]` if file is unchanged. |\n" +
"| `symbol_name` | Read only a named function/struct/class — eliminates an ast_query round-trip. |\n" +
"| `symbols_only=true` | Return only symbol list (~95% fewer tokens). Requires `include_ast=true`. Alias: `mode='symbols_only'`. |\n" +
"| `mode` | `full` (default) \\| `skeleton` (signatures + `{ ... }` stubs, bodies elided) \\| `symbols_only` |\n" +
"| `strip` | Remove content classes before line-numbering: `imports`, `license`, `block_comments`. Emits `[stripped: ...]` footer. |\n" +
"| `no_line_numbers=true` | Omit the ` 12│ ` line-number prefix (~10% savings). `line_number_interval=0` has the same effect. |\n" +
"| `line_number_interval=N` | Print line numbers only every N lines. |\n" +
"| `compact_line_numbers=true` | Use compact `12│` prefix instead of ` 12│ ` (no padding, no trailing space). |\n" +
"| `collapse_blank_lines=true` | Collapse consecutive blank lines to one. |\n" +
"| `max_lines=N` | Truncate output with omitted count notice. Applied after `line_start`/`line_end`. |\n" +
"| `paths=[...]` | Read multiple files in one call. Each file gets a `--- path ---` header. |\n\n" +
"All responses include `[etag: hex]` footer (8 hex chars) for use as `previous_etag` in subsequent reads.\n\n" +
"## Examples\n\n" +
"```json\n" +
"// Full file\n" +
`{"path": "main.go"}` + "\n\n" +
"// Etag check — returns unchanged notice if file hasn't changed\n" +
`{"path": "main.go", "previous_etag": "a3f9c2b1"}` + "\n\n" +
"// Read only one named symbol\n" +
`{"path": "server.go", "symbol_name": "handleFileRead"}` + "\n\n" +
"// Skeleton mode — signatures only, bodies elided\n" +
`{"path": "server.go", "mode": "skeleton"}` + "\n\n" +
"// Strip imports and license header\n" +
`{"path": "main.go", "strip": ["imports", "license"]}` + "\n\n" +
"// Batch read multiple files\n" +
`{"paths": ["a.go", "b.go"]}` + "\n\n" +
"// Specific line range\n" +
`{"path": "main.go", "line_start": 10, "line_end": 50}` + "\n" +
"```\n"
// helpFileSearch is the full flag documentation and examples for the file_search tool,
// served at filepuff://help/file_search.
const helpFileSearch = "# file_search — flags and examples\n\n" +
"## Output format\n\n" +
"Matches grouped by file. Each file section has matching lines prefixed by `L{line}│` and context lines prefixed by ` │`. Zero matches: `No matches found.`\n\n" +
"## Flags\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `verbose=true` | Emit `Found N matches in M files:` preamble (v1 behaviour). Default: false. |\n" +
"| `cluster=true` | Coalesce consecutive match lines into ranges (`L12-14│ text`). Drops context lines for density. |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size for pagination. Re-run with `cursor` to get next page. |\n" +
"| `context_lines` | Number of context lines around matches (default: 2). |\n" +
"| `ignore_case` | Case-insensitive search. |\n" +
"| `regex` | Treat pattern as regex (default: true). |\n" +
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
"| `paths` | Paths to search in (defaults to workspace root). |\n\n" +
"## Examples\n\n" +
"```json\n" +
"// Search for error-returning functions in Go files\n" +
`{"pattern": "func.*Error", "file_types": ["go"], "max_results": 20}` + "\n\n" +
"// Case-insensitive literal search\n" +
`{"pattern": "TODO", "ignore_case": true}` + "\n\n" +
"// Paginated search — fetch next page\n" +
`{"pattern": "import", "max_results": 50, "cursor": "<token from previous response>"}` + "\n\n" +
"// Clustered — dense view of many matches\n" +
`{"pattern": "return err", "file_types": ["go"], "cluster": true}` + "\n" +
"```\n"
// helpASTQuery is the full flag documentation and examples for the ast_query tool,
// served at filepuff://help/ast_query.
const helpASTQuery = "# ast_query — flags and examples\n\n" +
"## Output format\n\n" +
"Entries in format `**file:line** (node_type)` with code blocks and captured variables (`$NAME=value`). Zero matches: `No matches found.`\n\n" +
"## Flags\n\n" +
"| Flag | Effect |\n" +
"|------|--------|\n" +
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
"| `format` | `verbose` (default, full code+captures) \\| `compact` (one line per match) \\| `location` (file:line only) |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size (default: 100). |\n" +
"| `name_exact` | Exact symbol name to match. |\n" +
"| `name_matches` | Regex pattern to filter by name. |\n" +
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
"| `paths` | Paths to search in (defaults to workspace root). |\n\n" +
"## Pattern placeholders\n\n" +
"| Placeholder | Meaning |\n" +
"|-------------|----------|\n" +
"| `$NAME` | Matches a single node, captures as `$NAME` |\n" +
"| `$$$ARGS` | Matches zero or more nodes (variadic capture) |\n" +
"| `$_` | Wildcard — matches any single node, no capture |\n\n" +
"## Examples\n\n" +
"```json\n" +
"// All Go functions returning error\n" +
`{"pattern": "func $NAME($$$ARGS) error", "language": "go"}` + "\n\n" +
"// Python classes\n" +
`{"pattern": "class $NAME: $$$BODY", "language": "python"}` + "\n\n" +
"// Specific named function\n" +
`{"pattern": "func $NAME($$$ARGS)", "language": "go", "name_exact": "NewServer"}` + "\n\n" +
"// Compact output — one line per match\n" +
`{"pattern": "func $NAME($$$ARGS) error", "language": "go", "format": "compact"}` + "\n" +
"```\n"
// helpLSPQuery is the full flag documentation and examples for the lsp_query tool,
// served at filepuff://help/lsp_query.
const helpLSPQuery = "# lsp_query — flags and examples\n\n" +
"## Actions\n\n" +
"### hover\n" +
"Returns type/doc from LSP, falls back to AST node info. `verbose=true` adds `**Symbol Information**` header.\n\n" +
"### definition\n" +
"Returns `file:line:col` + 3-line code preview for each definition. `verbose=true` adds `Found N definition(s):` header.\n\n" +
"### references\n" +
"Returns references grouped by file. Flags:\n" +
"- `include_declaration` (default true) — include the declaration itself\n" +
"- `compact=true` — collapse to one line per file\n" +
"- `verbose=true` — add `Found N reference(s):` header\n\n" +
"Note: `include_declaration` and `compact` are errors when used with actions other than `references`.\n\n" +
"## Examples\n\n" +
"```json\n" +
"// Hover — type/doc at position\n" +
`{"action": "hover", "file": "server.go", "line": 45, "column": 6}` + "\n\n" +
"// Definition — where is this symbol defined?\n" +
`{"action": "definition", "file": "handler.go", "line": 23, "column": 10}` + "\n\n" +
"// References — all usages\n" +
`{"action": "references", "file": "types.go", "line": 5, "column": 6}` + "\n\n" +
"// References — compact (one line per file)\n" +
`{"action": "references", "file": "types.go", "line": 5, "column": 6, "compact": true}` + "\n" +
"```\n"
// helpEditApply is the full flag documentation and examples for the edit_apply tool,
// served at filepuff://help/edit_apply.
const helpEditApply = "# edit_apply — flags and examples\n\n" +
"## Response format (`response` flag)\n\n" +
"| Value | Output |\n" +
"|-------|--------|\n" +
"| `count` (default) | `+3 -1` added/removed line counts only |\n" +
"| `diff` | Full unified diff of changes made |\n" +
"| `none` | Empty response (silent success) |\n\n" +
"`compact_response=true` is a deprecated alias for `response=\"count\"` kept for pre-v2 compatibility.\n\n" +
"For code files (Go, TypeScript, JavaScript, Python, C, C++, Rust) syntax is validated before writing — the edit is rejected if it would produce invalid syntax.\n\n" +
"## Selector types\n\n" +
"### AST-mode selectors (code files)\n" +
"- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" +
"- `selector_name` — symbol name to match\n\n" +
"### Text-mode selectors (all files)\n" +
"- `selector_text` — exact text to match (must be unique, or use `selector_index`)\n" +
"- `selector_pattern` — regex pattern to match\n" +
"- `selector_line` / `selector_line_end` — line range\n\n" +
"### Shared\n" +
"- `selector_index` — index of match when multiple exist (default: 0)\n\n" +
"## Examples\n\n" +
"```json\n" +
"// AST mode — replace a named function\n" +
"{\n" +
` "file": "main.go",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_kind": "function_declaration",` + "\n" +
` "selector_name": "Hello",` + "\n" +
` "new_content": "func Hello() {\n\treturn\n}"` + "\n" +
"}\n\n" +
"// Text mode — replace a markdown header\n" +
"{\n" +
` "file": "README.md",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_text": "## Old Header",` + "\n" +
` "new_content": "## New Header"` + "\n" +
"}\n\n" +
"// Line range replacement\n" +
"{\n" +
` "file": "config.yaml",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_line": 5,` + "\n" +
` "selector_line_end": 10,` + "\n" +
` "new_content": "key: value"` + "\n" +
"}\n\n" +
"// Request full diff in response\n" +
"{\n" +
` "file": "main.go",` + "\n" +
` "operation": "replace",` + "\n" +
` "selector_name": "Hello",` + "\n" +
` "new_content": "func Hello() {}",` + "\n" +
` "response": "diff"` + "\n" +
"}\n" +
"```\n"
+474
View File
@@ -0,0 +1,474 @@
package server
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// TestMCPProtocolEndToEnd tests the complete MCP protocol communication flow.
func TestMCPProtocolEndToEnd(t *testing.T) {
tmpDir := t.TempDir()
// Create test files
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() string {
return "hello"
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
MaxParseSize: config.DefaultMaxParseSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
// Test: File read (ping removed — Change 3)
t.Run("file_read", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": testFile,
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Errorf("handleFileRead() error = %v", err)
}
if result == nil {
t.Fatal("handleFileRead() returned nil")
return
}
if len(result.Content) == 0 {
t.Fatal("handleFileRead() returned empty content")
}
})
// Test 3: AST query
t.Run("ast_query", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME() string",
"language": "go",
"paths": []interface{}{tmpDir},
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Errorf("handleASTQuery() error = %v", err)
}
if result == nil {
t.Fatal("handleASTQuery() returned nil")
return
}
})
// Test 4: Edit workflow
t.Run("edit_workflow", func(t *testing.T) {
// Apply edit directly (preview removed to avoid confusing LLMs)
applyReq := mcp.CallToolRequest{}
applyReq.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() string {\n\treturn \"goodbye\"\n}",
}
applyResult, err := srv.handleEditApply(ctx, applyReq)
if err != nil {
t.Errorf("handleEditApply() error = %v", err)
}
if applyResult == nil {
t.Fatal("handleEditApply() returned nil")
return
}
// Verify file changed after apply
modifiedContent, _ := os.ReadFile(testFile)
if string(modifiedContent) == content {
t.Error("apply should modify file")
}
})
}
// TestMCPToolDiscovery tests that all expected tools are registered.
func TestMCPToolDiscovery(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
// Note: The MCP server doesn't expose a method to list tools directly,
// but we can verify the server was created successfully
if srv.mcp == nil {
t.Fatal("MCP server not initialized")
}
// Ping tool removed (Change 3 — MCP protocol has own liveness check).
// Tools verified via integration tests in TestIntegrationFileOperations.
}
// TestMCPErrorResponses tests error handling following MCP spec.
func TestMCPErrorResponses(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 1024, // Small size to trigger errors
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
tests := []struct {
name string
handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
setupReq func() mcp.CallToolRequest
expectError bool
}{
{
name: "file_read_missing_path",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{}
return req
},
expectError: true,
},
{
name: "file_read_nonexistent",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": filepath.Join(tmpDir, "nonexistent.txt"),
}
return req
},
expectError: true,
},
{
name: "file_read_outside_workspace",
handler: srv.handleFileRead,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": "/etc/passwd",
}
return req
},
expectError: true,
},
{
name: "ast_query_missing_pattern",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"language": "go",
}
return req
},
expectError: true,
},
{
name: "ast_query_missing_language",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
}
return req
},
expectError: true,
},
{
name: "ast_query_unsupported_language",
handler: srv.handleASTQuery,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "cobol",
}
return req
},
expectError: true,
},
{
name: "edit_missing_file",
handler: srv.handleEditApply,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"operation": "replace",
}
return req
},
expectError: true,
},
{
name: "edit_missing_operation",
handler: srv.handleEditApply,
setupReq: func() mcp.CallToolRequest {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": filepath.Join(tmpDir, "test.go"),
}
return req
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := tt.setupReq()
result, err := tt.handler(ctx, request)
// Check for error - MCP tools return errors as nil error with error in result content
hasError := err != nil || (result != nil && len(result.Content) > 0)
if tt.expectError && !hasError {
t.Errorf("expected error but got none")
}
// Note: We don't check for unexpected success because some operations
// might legitimately return empty results
})
}
}
// TestMCPRequestResponseFlow tests the complete request/response flow.
func TestMCPRequestResponseFlow(t *testing.T) {
tmpDir := t.TempDir()
// Create test file
testFile := filepath.Join(tmpDir, "flow.go")
content := `package main
func Add(a, b int) int {
return a + b
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Test sequential operations
t.Run("sequential_operations", func(t *testing.T) {
// 1. Read file
readReq := mcp.CallToolRequest{}
readReq.Params.Arguments = map[string]interface{}{
"path": testFile,
}
readResult, err := srv.handleFileRead(ctx, readReq)
if err != nil {
t.Fatalf("handleFileRead() error = %v", err)
}
if readResult == nil {
t.Fatal("handleFileRead() returned nil")
return
}
// 2. Query AST
queryReq := mcp.CallToolRequest{}
queryReq.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME($$$ARGS) int",
"language": "go",
"paths": []interface{}{tmpDir},
}
queryResult, err := srv.handleASTQuery(ctx, queryReq)
if err != nil {
t.Fatalf("handleASTQuery() error = %v", err)
}
if queryResult == nil {
t.Fatal("handleASTQuery() returned nil")
return
}
// 3. Edit (no preview)
editReq := mcp.CallToolRequest{}
editReq.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Add",
"new_content": "func Add(a, b int) int {\n\treturn a + b + 1\n}",
}
editResult, err := srv.handleEditApply(ctx, editReq)
if err != nil {
t.Fatalf("handleEditApply() error = %v", err)
}
if editResult == nil {
t.Fatal("handleEditApply() returned nil")
return
}
})
}
// TestMCPConcurrentRequests tests handling of concurrent requests.
func TestMCPConcurrentRequests(t *testing.T) {
tmpDir := t.TempDir()
// Create multiple test files
for i := 0; i < 5; i++ {
testFile := filepath.Join(tmpDir, "test"+string(rune(i+48))+".go")
content := `package main
func Test() {
println("test")
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
// Run multiple concurrent requests
const numRequests = 10
done := make(chan bool, numRequests)
errors := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func(index int) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
}
_, err := srv.handleASTQuery(ctx, req)
if err != nil {
errors <- err
}
done <- true
}(i)
}
// Wait for all requests to complete
for i := 0; i < numRequests; i++ {
<-done
}
// Check for errors
close(errors)
for err := range errors {
t.Errorf("concurrent request failed: %v", err)
}
}
// TestMCPContextCancellation tests handling of context cancellation.
func TestMCPContextCancellation(t *testing.T) {
tmpDir := t.TempDir()
// Create a large directory structure to ensure operation takes time
for i := 0; i < 10; i++ {
subdir := filepath.Join(tmpDir, "subdir"+string(rune(i+48)))
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
for j := 0; j < 10; j++ {
testFile := filepath.Join(subdir, "test"+string(rune(j+48))+".go")
content := `package main
func Test() {
println("test")
}
`
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
}
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: config.DefaultMaxFileSize,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
// Create a context with a very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
}
// This should either complete quickly or handle cancellation gracefully
_, err = srv.handleASTQuery(ctx, req)
// We don't check for specific error as it might complete before timeout
// The important thing is it doesn't panic or hang
if err != nil {
t.Logf("handleASTQuery with cancelled context: %v", err)
}
}
+156
View File
@@ -0,0 +1,156 @@
package server
import (
"context"
"fmt"
"net/url"
"os"
"strings"
xxhash "github.com/cespare/xxhash/v2"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
)
// helpResources maps a tool name to its help content constant.
var helpResources = map[string]string{
"file_read": helpFileRead,
"file_search": helpFileSearch,
"ast_query": helpASTQuery,
"lsp_query": helpLSPQuery,
"edit_apply": helpEditApply,
}
// registerResources registers one filepuff://help/<tool> resource per tool.
// Each resource returns Markdown-formatted flag docs and examples.
func (s *Server) registerResources() {
for toolName, content := range helpResources {
uri := "filepuff://help/" + toolName
name := "help/" + toolName
description := "Flag documentation and examples for the " + toolName + " tool."
captured := content // capture for closure
s.mcp.AddResource(
mcp.NewResource(uri, name,
mcp.WithResourceDescription(description),
mcp.WithMIMEType("text/markdown"),
),
func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "text/markdown",
Text: captured,
},
}, nil
},
)
}
}
// readHelpResource is a convenience handler that can be used directly when a
// single resource handler is needed. It is kept exported for testability.
func readHelpResource(uri string) mcpserver.ResourceHandlerFunc {
return func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// Extract tool name from filepuff://help/<tool>
const prefix = "filepuff://help/"
if len(uri) <= len(prefix) {
return nil, fmt.Errorf("invalid help URI: %s", uri)
}
toolName := uri[len(prefix):]
content, ok := helpResources[toolName]
if !ok {
return nil, fmt.Errorf("no help content for tool: %s", toolName)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "text/markdown",
Text: content,
},
}, nil
}
}
// registerReadResource registers the filepuff://read/{+path} resource template.
// The handler re-reads the file, validates the etag query param if provided,
// and returns the raw file content (no line-number formatting).
//
// URI format: filepuff://read/<absolute-path>?etag=<etag>
// The etag param is optional. If supplied and the file has changed, the handler
// returns an error so the caller re-runs file_read to get a fresh ResourceLink.
func (s *Server) registerReadResource() {
const uriTemplate = "filepuff://read/{+path}"
s.mcp.AddResourceTemplate(
mcp.NewResourceTemplate(uriTemplate, "file-read",
mcp.WithTemplateDescription("Raw content of a file previously read via file_read. "+
"Fetch when file_read returns a ResourceLink instead of inlining content. "+
"URI: filepuff://read/<path>?etag=<etag>"),
),
func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return s.handleReadResource(req)
},
)
}
// handleReadResource is the resource handler for filepuff://read/{+path} URIs.
func (s *Server) handleReadResource(req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
rawURI := req.Params.URI
// Parse path and etag from the URI.
// URI shape: filepuff://read/<path>[?etag=<hex>]
const scheme = "filepuff://read/"
if !strings.HasPrefix(rawURI, scheme) {
return nil, fmt.Errorf("invalid read resource URI: %s", rawURI)
}
rest := rawURI[len(scheme):]
// Split off query string to get the path.
filePath := rest
var expectedEtag string
if qIdx := strings.IndexByte(rest, '?'); qIdx >= 0 {
filePath = rest[:qIdx]
qs, err := url.ParseQuery(rest[qIdx+1:])
if err == nil {
expectedEtag = qs.Get("etag")
}
}
if filePath == "" {
return nil, fmt.Errorf("read resource URI missing path")
}
if !s.cfg.IsPathAllowed(filePath) {
return nil, fmt.Errorf("path is outside workspace root")
}
content, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied: %s", filePath)
}
return nil, fmt.Errorf("error reading file: %s", filePath)
}
// Validate etag if provided — detect stale references.
if expectedEtag != "" {
fullHash := fmt.Sprintf("%016x", xxhash.Sum64(content))
currentEtag := fullHash[:8]
if expectedEtag != currentEtag && !strings.HasPrefix(fullHash, expectedEtag) && !strings.HasPrefix(expectedEtag, currentEtag) {
return nil, fmt.Errorf("file changed since ResourceLink was issued (expected etag %s, got %s); re-run file_read to get fresh content", expectedEtag, currentEtag)
}
}
mimeType := detectMIMEType(filePath)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: rawURI,
MIMEType: mimeType,
Text: string(content),
},
}, nil
}
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"context"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestRegisterResources_AllToolsHaveResource(t *testing.T) {
// Verify that registerResources wires up without panicking.
_ = newTestServer(t, t.TempDir())
expectedURIs := []string{
"filepuff://help/file_read",
"filepuff://help/file_search",
"filepuff://help/ast_query",
"filepuff://help/lsp_query",
"filepuff://help/edit_apply",
}
for _, uri := range expectedURIs {
t.Run(uri, func(t *testing.T) {
handler := readHelpResource(uri)
contents, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err != nil {
t.Fatalf("readHelpResource(%q) error = %v", uri, err)
}
if len(contents) == 0 {
t.Fatalf("readHelpResource(%q) returned empty contents", uri)
}
tc, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatalf("readHelpResource(%q) contents[0] is not TextResourceContents", uri)
}
if tc.MIMEType != "text/markdown" {
t.Errorf("MIMEType = %q, want %q", tc.MIMEType, "text/markdown")
}
if len(tc.Text) == 0 {
t.Errorf("Text is empty for %q", uri)
}
if !strings.HasPrefix(tc.Text, "#") {
t.Errorf("expected markdown (# heading) for %q, got: %q", uri, tc.Text[:min(50, len(tc.Text))])
}
if tc.URI != uri {
t.Errorf("URI = %q, want %q", tc.URI, uri)
}
})
}
}
func TestReadHelpResource_UnknownTool(t *testing.T) {
handler := readHelpResource("filepuff://help/nonexistent")
_, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err == nil {
t.Fatal("expected error for unknown tool, got nil")
}
}
func TestReadHelpResource_InvalidURI(t *testing.T) {
handler := readHelpResource("filepuff://help/")
_, err := handler(context.Background(), mcp.ReadResourceRequest{})
if err == nil {
t.Fatal("expected error for empty tool name, got nil")
}
}
func TestHelpContent_NotEmpty(t *testing.T) {
cases := map[string]string{
"file_read": helpFileRead,
"file_search": helpFileSearch,
"ast_query": helpASTQuery,
"lsp_query": helpLSPQuery,
"edit_apply": helpEditApply,
}
for name, content := range cases {
t.Run(name, func(t *testing.T) {
if len(content) == 0 {
t.Errorf("help content for %q is empty", name)
}
if !strings.Contains(content, "##") {
t.Errorf("expected markdown sections (##) in help content for %q", name)
}
if !strings.Contains(content, "```") {
t.Errorf("expected code fences in help content for %q", name)
}
})
}
}
+183 -751
View File
File diff suppressed because it is too large Load Diff
+421 -92
View File
@@ -5,7 +5,9 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
@@ -29,8 +31,8 @@ func TestNew(t *testing.T) {
if srv == nil {
t.Fatal("New() returned nil server")
return
}
if srv.cfg != cfg {
t.Error("server config mismatch")
}
@@ -48,44 +50,6 @@ func TestNew(t *testing.T) {
}
}
func TestHandlePing(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
result, err := srv.handlePing(ctx, req)
if err != nil {
t.Errorf("handlePing() error = %v", err)
}
if result == nil {
t.Fatal("handlePing() returned nil result")
}
// Check that the result contains "pong"
contents := result.Content
if len(contents) == 0 {
t.Fatal("handlePing() returned empty content")
}
textContent, ok := contents[0].(mcp.TextContent)
if !ok {
t.Fatal("handlePing() did not return text content")
}
if textContent.Text != "pong" {
t.Errorf("handlePing() = %v, want 'pong'", textContent.Text)
}
}
func TestHandleFileRead(t *testing.T) {
tmpDir := t.TempDir()
@@ -123,6 +87,7 @@ func Hello() {
if result == nil {
t.Fatal("handleFileRead() returned nil result")
return
}
contents := result.Content
@@ -179,6 +144,7 @@ func Hello() {
if result == nil {
t.Fatal("handleFileRead() returned nil result")
return
}
contents := result.Content
@@ -270,6 +236,7 @@ func Goodbye() error {
if result == nil {
t.Fatal("handleASTQuery() returned nil result")
return
}
contents := result.Content
@@ -278,55 +245,6 @@ func Goodbye() error {
}
}
func TestHandleEditPreview(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
}
result, err := srv.handleEdit(ctx, req, false)
if err != nil {
t.Errorf("handleEdit(preview) error = %v", err)
}
if result == nil {
t.Fatal("handleEdit(preview) returned nil result")
}
// Verify file was NOT modified (it's just a preview)
fileContent, _ := os.ReadFile(testFile)
if string(fileContent) != content {
t.Error("handleEdit(preview) should not modify the file")
}
}
func TestHandleEditApply(t *testing.T) {
tmpDir := t.TempDir()
@@ -360,18 +278,429 @@ func Hello() {
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
}
result, err := srv.handleEdit(ctx, req, true)
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Errorf("handleEdit(apply) error = %v", err)
t.Errorf("handleEditApply error = %v", err)
}
if result == nil {
t.Fatal("handleEdit(apply) returned nil result")
t.Fatal("handleEditApply returned nil result")
return
}
// Verify file WAS modified
fileContent, _ := os.ReadFile(testFile)
if string(fileContent) == content {
t.Error("handleEdit(apply) should modify the file")
t.Error("handleEditApply should modify the file")
}
}
// TestHandleFileReadMaxFileSize verifies that handleFileRead rejects files
// exceeding MaxFileSize via os.Stat before loading them into memory (T-03, S-01).
func TestHandleFileReadMaxFileSize(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "big.txt")
content := make([]byte, 1024) // 1KB file
for i := range content {
content[i] = 'A'
}
if err := os.WriteFile(testFile, content, 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
// Set MaxFileSize smaller than the file
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 512, // 512 bytes — smaller than our 1KB file
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": testFile,
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead() returned Go error: %v", err)
}
// The result should indicate an error (file too large)
if result == nil {
t.Fatal("handleFileRead() returned nil result")
}
if !result.IsError {
t.Error("expected IsError=true for file exceeding MaxFileSize")
}
contents := result.Content
if len(contents) == 0 {
t.Fatal("expected non-empty content with error message")
}
textContent, ok := contents[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if !strings.Contains(textContent.Text, "too large") {
t.Errorf("expected 'too large' error message, got: %s", textContent.Text)
}
}
// TestSplitLinesLargeFile tests the splitLines function with a large file (>1MB)
// to exercise the bufio.Scanner path including the scanner.Err() check (T-07, C-05).
func TestSplitLinesLargeFile(t *testing.T) {
// Build a string >1MB with known line count
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
sb.WriteString(strings.Repeat("x", 60))
sb.WriteByte('\n')
}
largeContent := sb.String()
// Verify it's large enough to trigger the scanner path
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes, need >1MB", len(largeContent))
}
lines := splitLines(largeContent)
// String ends with \n, so splitLines adds an empty trailing element
// (matching the behavior of strings.Split for the small-file path)
expectedLines := lineCount + 1 // lineCount lines + 1 trailing empty
if len(lines) != expectedLines {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), expectedLines)
}
// Check first and last actual lines
if lines[0] != strings.Repeat("x", 60) {
t.Errorf("first line mismatch: got %q", lines[0][:20])
}
if lines[lineCount-1] != strings.Repeat("x", 60) {
t.Errorf("last content line mismatch: got %q", lines[lineCount-1][:20])
}
if lines[lineCount] != "" {
t.Errorf("expected empty trailing line, got %q", lines[lineCount])
}
}
// TestSplitLinesLargeFileNoTrailingNewline verifies splitLines for large files
// without a trailing newline.
func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(strings.Repeat("y", 60))
}
largeContent := sb.String()
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
if len(lines) != lineCount {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), lineCount)
}
}
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
// (up to the 1MB buffer limit set in C-05 fix).
func TestSplitLinesLongLine(t *testing.T) {
// Create content with one very long line (500KB) embedded in a >1MB file
shortLines := strings.Repeat("short line content\n", 60000) // ~60KB * ~1 = ~1.08MB
longLine := strings.Repeat("L", 500*1024) // 500KB line
largeContent := shortLines + longLine + "\n"
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
// Should not crash and should return some lines
if len(lines) == 0 {
t.Fatal("splitLines returned no lines for valid content")
}
// The long line should be present somewhere in the output
foundLong := false
for _, line := range lines {
if len(line) >= 500*1024 {
foundLong = true
break
}
}
if !foundLong {
t.Error("the 500KB long line was not found in splitLines output")
}
}
// TestHandleEditApplyResponseCount verifies the default response=count format "+N -M".
func TestHandleEditApplyResponseCount(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
// no response flag → default "count"
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
// Should be "+N -M" format
text := textContent.Text
if !strings.HasPrefix(text, "+") {
t.Errorf("response=count should start with +, got: %q", text)
}
if !strings.Contains(text, " -") {
t.Errorf("response=count should contain -N, got: %q", text)
}
// Must NOT contain diff syntax or old preamble
if strings.Contains(text, "@@") || strings.Contains(text, "Edit Applied") {
t.Errorf("response=count must not contain diff markers, got: %q", text)
}
}
// TestHandleEditApplyResponseDiff verifies response=diff returns unified diff.
func TestHandleEditApplyResponseDiff(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
"response": "diff",
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
text := textContent.Text
if !strings.Contains(text, "diff") {
t.Errorf("response=diff should contain diff, got: %q", text)
}
// Must NOT have old "Edit Applied Successfully" preamble
if strings.Contains(text, "Edit Applied Successfully") {
t.Errorf("v2 diff should not have old preamble, got: %q", text)
}
}
// TestHandleEditApplyResponseNone verifies response=none returns empty string.
func TestHandleEditApplyResponseNone(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func Hello() {
println("Hello")
}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"file": testFile,
"operation": "replace",
"selector_kind": "function_declaration",
"selector_name": "Hello",
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
"response": "none",
}
result, err := srv.handleEditApply(ctx, req)
if err != nil {
t.Fatalf("handleEditApply error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleEditApply returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if textContent.Text != "" {
t.Errorf("response=none should return empty string, got: %q", textContent.Text)
}
}
// TestHandleFileReadBatchDedup verifies that identical files in batch mode emit [duplicate of ...].
func TestHandleFileReadBatchDedup(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "a.go")
content := `package main
func Hello() {}
`
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write a.go: %v", err)
}
// Make b.go with identical content
testFile2 := filepath.Join(tmpDir, "b.go")
if err := os.WriteFile(testFile2, []byte(content), 0600); err != nil {
t.Fatalf("failed to write b.go: %v", err)
}
// c.go with different content
testFile3 := filepath.Join(tmpDir, "c.go")
if err := os.WriteFile(testFile3, []byte("package main\n"), 0600); err != nil {
t.Fatalf("failed to write c.go: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"paths": []interface{}{testFile, testFile2, testFile3},
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead() error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleFileRead() returned empty result")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
text := textContent.Text
if !strings.Contains(text, "[duplicate of") {
t.Errorf("expected duplicate pointer for b.go, got:\n%s", text)
}
// a.go should have full content
if !strings.Contains(text, "--- "+testFile+" ---") {
t.Errorf("expected a.go header, got:\n%s", text)
}
// c.go should have full content (different hash)
if !strings.Contains(text, "--- "+testFile3+" ---") {
t.Errorf("expected c.go header, got:\n%s", text)
}
}
// TestHandleFileSearchVerbose verifies verbose=true emits "Found N matches in M files:" preamble.
func TestHandleFileSearchVerbose(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
if err := os.WriteFile(testFile, []byte("package main\n\nfunc Hello() {}\n"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024, SearchTimeout: 10 * time.Second}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if srv.searcher == nil {
t.Skip("ripgrep not available")
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "Hello",
"paths": []interface{}{tmpDir},
"verbose": true,
}
result, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("handleFileSearch error = %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("handleFileSearch returned empty")
}
textContent, ok := result.Content[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if !strings.Contains(textContent.Text, "Found ") {
t.Errorf("verbose=true should emit preamble, got:\n%s", textContent.Text)
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package server implements the MCP server for file operations.
package server
import (
"sync/atomic"
"unsafe"
)
// SessionPrefs holds client-declared session-wide preferences parsed from
// InitializeRequest.Params.Capabilities.Experimental["filepuff"].
//
// These act as defaults; explicit per-call flags always override them.
//
// Supported keys in the "filepuff" experimental map:
//
// terse bool — no-op (v2 default is already terse; reserved for future)
// default_format string — ast_query format default ("verbose"|"compact"|"location")
// default_max_results int — file_search and ast_query max_results when not supplied
// default_cluster bool — file_search cluster default
// compact_refs bool — lsp_query references compact default
// line_numbers string — file_read line prefix default ("none"|"compact"|"full")
// resource_link_threshold int — per-session override for cfg.ResourceLinkThresholdBytes
type SessionPrefs struct {
// ASTQueryFormat is the default format for ast_query ("verbose", "compact", "location").
// Empty string means "use handler built-in default".
ASTQueryFormat string
// DefaultMaxResults is the default max_results for file_search and ast_query.
// 0 means "use handler built-in default".
DefaultMaxResults int
// DefaultCluster is the default cluster flag for file_search.
// nil means "use handler built-in default (false)".
DefaultCluster *bool
// CompactRefs is the default compact flag for lsp_query action=references.
// nil means "use handler built-in default (false)".
CompactRefs *bool
// LineNumbers controls the file_read line prefix default.
// "" = use handler built-in default (full).
// "none" = no line numbers.
// "compact" = compact prefix (N│content).
// "full" = standard padded prefix ( N│ content).
LineNumbers string
// ResourceLinkThreshold overrides cfg.ResourceLinkThresholdBytes for this session.
// 0 means "use config default".
ResourceLinkThreshold int
}
// boolPtr returns a pointer to a bool value.
func boolPtr(b bool) *bool { return &b }
// ParseSessionPrefs parses the raw map from
// InitializeRequest.Params.Capabilities.Experimental["filepuff"].
// Unknown keys are silently ignored. Type mismatches for individual keys are
// silently ignored (key is treated as absent). Returns zero-value SessionPrefs
// when raw is nil or empty — callers should treat zero values as "use built-in defaults".
func ParseSessionPrefs(raw map[string]any) SessionPrefs {
if len(raw) == 0 {
return SessionPrefs{}
}
var p SessionPrefs
if v, ok := raw["default_format"]; ok {
if s, ok := v.(string); ok {
switch s {
case "verbose", "compact", "location":
p.ASTQueryFormat = s
}
}
}
if v, ok := raw["default_max_results"]; ok {
if n := toInt(v); n > 0 {
p.DefaultMaxResults = n
}
}
if v, ok := raw["default_cluster"]; ok {
if b, ok := v.(bool); ok {
p.DefaultCluster = boolPtr(b)
}
}
if v, ok := raw["compact_refs"]; ok {
if b, ok := v.(bool); ok {
p.CompactRefs = boolPtr(b)
}
}
if v, ok := raw["line_numbers"]; ok {
if s, ok := v.(string); ok {
switch s {
case "none", "compact", "full":
p.LineNumbers = s
}
}
}
if v, ok := raw["resource_link_threshold"]; ok {
if n := toInt(v); n >= 0 {
p.ResourceLinkThreshold = n
}
}
return p
}
// toInt converts numeric JSON-decoded values (float64, int, int64) to int.
// Returns 0 for unsupported types or negative values.
func toInt(v any) int {
switch n := v.(type) {
case float64:
if n >= 0 {
return int(n)
}
case int:
if n >= 0 {
return n
}
case int64:
if n >= 0 {
return int(n)
}
}
return 0
}
// sessionPrefsPtr is an atomic pointer helper for thread-safe access to *SessionPrefs.
// We use atomic store/load so the hook (called once at init) and handlers (many goroutines)
// never race. The prefs are write-once after initialization.
type sessionPrefsPtr struct {
p unsafe.Pointer // *SessionPrefs
}
func (sp *sessionPrefsPtr) Store(prefs *SessionPrefs) {
atomic.StorePointer(&sp.p, unsafe.Pointer(prefs))
}
func (sp *sessionPrefsPtr) Load() *SessionPrefs {
return (*SessionPrefs)(atomic.LoadPointer(&sp.p))
}
+313
View File
@@ -0,0 +1,313 @@
package server
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// ---- Session prefs integration tests ----
// setSessionPrefs injects prefs directly on the server (bypasses MCP hook machinery).
func setSessionPrefs(srv *Server, prefs SessionPrefs) {
srv.sessionPrefs.Store(&prefs)
}
// TestSessionPrefsFileReadLineNumbersNone verifies that session pref line_numbers=none
// disables line-number prefixes and is overridden by explicit compact_line_numbers=true.
func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
content := "package main\n\nfunc Foo() {}\n"
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("write file: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New: %v", err)
}
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"line_numbers": "none"}))
ctx := context.Background()
// Without explicit override: session pref should suppress line numbers.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead: %v", err)
}
text := result.Content[0].(mcp.TextContent).Text
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
if strings.Contains(text, " 1│") {
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
}
// Explicit per-call compact_line_numbers=true should override session none.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"path": testFile,
"compact_line_numbers": true,
}
result2, err := srv.handleFileRead(ctx, req2)
if err != nil {
t.Fatalf("handleFileRead (explicit compact): %v", err)
}
text2 := result2.Content[0].(mcp.TextContent).Text
// Compact format emits "1│" prefix.
if !strings.Contains(text2, "\u2502") {
t.Errorf("explicit compact_line_numbers should override session none, got:\n%s", text2)
}
}
// TestSessionPrefsFileReadLineNumbersCompact verifies line_numbers=compact session pref.
func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "x.go")
if err := os.WriteFile(testFile, []byte("package main\nfunc Bar() {}\n"), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"line_numbers": "compact"}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
text := result.Content[0].(mcp.TextContent).Text
// Standard padded prefix is " 1│ "; compact is "1│".
if strings.Contains(text, " 1\u2502") {
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
}
// Should still have the │ separator somewhere.
if !strings.Contains(text, "\u2502") {
t.Errorf("session line_numbers=compact should still have \u2502 separator, got:\n%s", text)
}
}
// TestSessionPrefsResourceLinkThreshold verifies per-session threshold override.
func TestSessionPrefsResourceLinkThreshold(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "big.go")
var sb strings.Builder
sb.WriteString("package main\n\nfunc Foo() {\n")
for i := 0; i < 15; i++ {
sb.WriteString("// comment line\n")
}
sb.WriteString("}\n")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
// Config threshold = 0 (disabled) so content is always inlined by default.
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20, ResourceLinkThresholdBytes: 0}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Set session threshold = 10 bytes (tiny), so any real file triggers resource-link.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"resource_link_threshold": float64(10)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
if len(result.Content) == 0 {
t.Fatal("expected content")
}
_, isLink := result.Content[0].(mcp.ResourceLink)
_, isText := result.Content[0].(mcp.TextContent)
if !isLink && isText {
t.Error("expected ResourceLink when session threshold is very small, got TextContent")
}
// force_inline should still bypass even a session threshold.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{"path": testFile, "force_inline": true}
result2, _ := srv.handleFileRead(ctx, req2)
if _, ok := result2.Content[0].(mcp.TextContent); !ok {
t.Error("force_inline=true should bypass session threshold and return TextContent")
}
}
// TestSessionPrefsASTQueryFormat verifies default_format session pref for ast_query.
func TestSessionPrefsASTQueryFormat(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.go")
if err := os.WriteFile(testFile, []byte("package main\n\nfunc Greet() {}\n"), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_format": "compact"}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
// no format key → session default should apply
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("handleASTQuery: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// Compact format emits one-line results without "**file:line**" markers.
if strings.Contains(text, "**") {
t.Errorf("session default_format=compact: expected compact output (no **), got:\n%s", text)
}
// Explicit format=verbose should override session compact.
req2 := mcp.CallToolRequest{}
req2.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
"format": "verbose",
}
result2, _ := srv.handleASTQuery(ctx, req2)
if result2 != nil && len(result2.Content) > 0 {
text2 := result2.Content[0].(mcp.TextContent).Text
if !strings.Contains(text2, "**") {
t.Errorf("explicit format=verbose should override session compact, got:\n%s", text2)
}
}
}
// TestSessionPrefsASTQueryMaxResults verifies default_max_results for ast_query.
func TestSessionPrefsASTQueryMaxResults(t *testing.T) {
tmpDir := t.TempDir()
// Build a file with 5 functions.
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Session pref: max 2 results.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_max_results": float64(2)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
// no max_results → session pref of 2 should apply
}
result, err := srv.handleASTQuery(ctx, req)
if err != nil {
t.Fatalf("handleASTQuery: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// With 5 funcs and max=2, output should mention remaining.
if !strings.Contains(text, "remaining") && !strings.Contains(text, "cursor") {
t.Errorf("session max_results=2 with 5 matches should produce cursor line, got:\n%s", text)
}
}
// TestSessionPrefsFileSearchDefaultCluster verifies default_cluster session pref.
func TestSessionPrefsFileSearchDefaultCluster(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "x.go")
content := "package main\n\nfunc Foo() {}\nfunc Foo2() {}\n"
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{
WorkspaceRoot: tmpDir,
MaxFileSize: 1 << 20,
SearchTimeout: 10 * time.Second,
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New: %v", err)
}
if srv.searcher == nil {
t.Skip("ripgrep not available")
}
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_cluster": true}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func",
"paths": []interface{}{tmpDir},
// no cluster flag → session default_cluster=true should apply
}
result, err := srv.handleFileSearch(ctx, req)
if err != nil {
t.Fatalf("handleFileSearch: %v", err)
}
if result == nil || len(result.Content) == 0 {
t.Skip("search returned no results")
}
// Verify call succeeded (cluster behaviour is ripgrep-version dependent).
_ = result.Content[0].(mcp.TextContent).Text
}
// TestSessionPrefsMaxResultsExplicitOverride verifies explicit call-time max_results
// overrides session pref for ast_query.
func TestSessionPrefsMaxResultsExplicitOverride(t *testing.T) {
tmpDir := t.TempDir()
var sb strings.Builder
sb.WriteString("package main\n\n")
for i := 0; i < 5; i++ {
sb.WriteString(fmt.Sprintf("func Fn%c() {}\n\n", rune('A'+i)))
}
testFile := filepath.Join(tmpDir, "many.go")
if err := os.WriteFile(testFile, []byte(sb.String()), 0600); err != nil {
t.Fatalf("write: %v", err)
}
cfg := &config.Config{WorkspaceRoot: tmpDir, MaxFileSize: 1 << 20}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, _ := New(cfg, logger)
// Session wants 2, caller supplies 10 — all 5 should fit without cursor.
setSessionPrefs(srv, ParseSessionPrefs(map[string]any{"default_max_results": float64(2)}))
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"pattern": "func $NAME()",
"language": "go",
"paths": []interface{}{tmpDir},
"max_results": 10, // explicit override
}
result, _ := srv.handleASTQuery(ctx, req)
if result == nil || len(result.Content) == 0 {
t.Fatal("empty result")
}
text := result.Content[0].(mcp.TextContent).Text
// With max_results=10 and only 5 funcs, no cursor line expected.
if strings.Contains(text, "remaining") {
t.Errorf("explicit max_results=10 should override session 2; 5 funcs fit; no cursor expected, got:\n%s", text)
}
}
+186
View File
@@ -0,0 +1,186 @@
package server
import (
"testing"
)
// TestParseSessionPrefsEmpty verifies zero-value result for nil/empty input.
func TestParseSessionPrefsEmpty(t *testing.T) {
p := ParseSessionPrefs(nil)
if p.ASTQueryFormat != "" {
t.Errorf("ASTQueryFormat: want \"\", got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 0 {
t.Errorf("DefaultMaxResults: want 0, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster != nil {
t.Errorf("DefaultCluster: want nil, got %v", *p.DefaultCluster)
}
if p.CompactRefs != nil {
t.Errorf("CompactRefs: want nil, got %v", *p.CompactRefs)
}
if p.LineNumbers != "" {
t.Errorf("LineNumbers: want \"\", got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("ResourceLinkThreshold: want 0, got %d", p.ResourceLinkThreshold)
}
// Also test with an empty (non-nil) map.
p2 := ParseSessionPrefs(map[string]any{})
if p2.ASTQueryFormat != "" || p2.DefaultMaxResults != 0 {
t.Error("empty map should produce zero-value prefs")
}
}
// TestParseSessionPrefsAllFields verifies full round-trip with all supported keys.
func TestParseSessionPrefsAllFields(t *testing.T) {
raw := map[string]any{
"terse": true, // no-op; should not produce an error
"default_format": "compact",
"default_max_results": float64(50), // JSON numbers decode as float64
"default_cluster": true,
"compact_refs": true,
"line_numbers": "none",
"resource_link_threshold": float64(32768),
}
p := ParseSessionPrefs(raw)
if p.ASTQueryFormat != "compact" {
t.Errorf("ASTQueryFormat: want \"compact\", got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 50 {
t.Errorf("DefaultMaxResults: want 50, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster == nil || !*p.DefaultCluster {
t.Errorf("DefaultCluster: want true, got %v", p.DefaultCluster)
}
if p.CompactRefs == nil || !*p.CompactRefs {
t.Errorf("CompactRefs: want true, got %v", p.CompactRefs)
}
if p.LineNumbers != "none" {
t.Errorf("LineNumbers: want \"none\", got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 32768 {
t.Errorf("ResourceLinkThreshold: want 32768, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsLineNumbersVariants tests all valid line_numbers values.
func TestParseSessionPrefsLineNumbersVariants(t *testing.T) {
for _, want := range []string{"none", "compact", "full"} {
p := ParseSessionPrefs(map[string]any{"line_numbers": want})
if p.LineNumbers != want {
t.Errorf("line_numbers=%q: got %q", want, p.LineNumbers)
}
}
// Invalid value → ignored (empty string).
p := ParseSessionPrefs(map[string]any{"line_numbers": "bogus"})
if p.LineNumbers != "" {
t.Errorf("invalid line_numbers should be ignored, got %q", p.LineNumbers)
}
}
// TestParseSessionPrefsFormatVariants tests all valid default_format values.
func TestParseSessionPrefsFormatVariants(t *testing.T) {
for _, want := range []string{"verbose", "compact", "location"} {
p := ParseSessionPrefs(map[string]any{"default_format": want})
if p.ASTQueryFormat != want {
t.Errorf("default_format=%q: got %q", want, p.ASTQueryFormat)
}
}
// Invalid value → ignored.
p := ParseSessionPrefs(map[string]any{"default_format": "yaml"})
if p.ASTQueryFormat != "" {
t.Errorf("invalid format should be ignored, got %q", p.ASTQueryFormat)
}
}
// TestParseSessionPrefsTypeMismatch verifies that wrong types are silently ignored.
func TestParseSessionPrefsTypeMismatch(t *testing.T) {
raw := map[string]any{
"default_format": 123, // wrong type (int instead of string)
"default_max_results": "fifty", // wrong type
"default_cluster": "yes", // wrong type (string instead of bool)
"compact_refs": 42, // wrong type
"line_numbers": true, // wrong type
"resource_link_threshold": "big", // wrong type
}
p := ParseSessionPrefs(raw)
if p.ASTQueryFormat != "" {
t.Errorf("type mismatch for format should produce empty, got %q", p.ASTQueryFormat)
}
if p.DefaultMaxResults != 0 {
t.Errorf("type mismatch for max_results should produce 0, got %d", p.DefaultMaxResults)
}
if p.DefaultCluster != nil {
t.Errorf("type mismatch for cluster should produce nil")
}
if p.CompactRefs != nil {
t.Errorf("type mismatch for compact_refs should produce nil")
}
if p.LineNumbers != "" {
t.Errorf("type mismatch for line_numbers should produce empty, got %q", p.LineNumbers)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("type mismatch for threshold should produce 0, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsNegativeValues verifies negative numbers are rejected.
func TestParseSessionPrefsNegativeValues(t *testing.T) {
p := ParseSessionPrefs(map[string]any{
"default_max_results": float64(-5),
"resource_link_threshold": float64(-1),
})
if p.DefaultMaxResults != 0 {
t.Errorf("negative max_results should be rejected, got %d", p.DefaultMaxResults)
}
if p.ResourceLinkThreshold != 0 {
t.Errorf("negative threshold should be rejected, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsIntCoercion verifies int and int64 inputs also work.
func TestParseSessionPrefsIntCoercion(t *testing.T) {
p := ParseSessionPrefs(map[string]any{
"default_max_results": int(25),
"resource_link_threshold": int64(16384),
})
if p.DefaultMaxResults != 25 {
t.Errorf("int max_results: want 25, got %d", p.DefaultMaxResults)
}
if p.ResourceLinkThreshold != 16384 {
t.Errorf("int64 threshold: want 16384, got %d", p.ResourceLinkThreshold)
}
}
// TestParseSessionPrefsClusterFalse ensures default_cluster=false stores a non-nil false.
func TestParseSessionPrefsClusterFalse(t *testing.T) {
p := ParseSessionPrefs(map[string]any{"default_cluster": false})
if p.DefaultCluster == nil {
t.Error("default_cluster=false should store non-nil pointer")
}
if *p.DefaultCluster != false {
t.Error("default_cluster=false: want false pointer")
}
}
// TestSessionPrefsAtomicStore verifies sessionPrefsPtr is readable after Store.
func TestSessionPrefsAtomicStore(t *testing.T) {
var sp sessionPrefsPtr
if sp.Load() != nil {
t.Error("uninitialised Load() should return nil")
}
prefs := ParseSessionPrefs(map[string]any{"default_format": "compact"})
sp.Store(&prefs)
loaded := sp.Load()
if loaded == nil {
t.Fatal("Load() returned nil after Store")
}
if loaded.ASTQueryFormat != "compact" {
t.Errorf("loaded format: want compact, got %q", loaded.ASTQueryFormat)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Package util provides shared utility functions and caches.
package util
import (
"fmt"
"regexp"
"sync"
)
const (
// MaxPatternLength is the maximum allowed length for regex patterns.
// This prevents memory issues from extremely long patterns.
MaxPatternLength = 1000
// MaxCacheSize is the maximum number of patterns to cache.
// When exceeded, the cache is cleared to prevent unbounded memory growth.
MaxCacheSize = 10000
)
// regexCache is a global thread-safe cache for compiled regular expressions.
// Uses sync.RWMutex with a regular map so that ClearRegexCache can atomically
// clear the map and reset the count in a single lock acquisition.
var (
cacheMu sync.RWMutex
regexCache = make(map[string]*regexp.Regexp)
)
// RegexError represents an error during regex compilation or validation.
type RegexError struct {
Pattern string
Reason string
Err error
}
func (e *RegexError) Error() string {
if e.Err != nil {
return fmt.Sprintf("regex error for pattern %q: %s: %v", e.Pattern, e.Reason, e.Err)
}
return fmt.Sprintf("regex error for pattern %q: %s", e.Pattern, e.Reason)
}
func (e *RegexError) Unwrap() error {
return e.Err
}
// ValidatePattern validates a regex pattern for safety.
// Returns an error if the pattern is too long or appears malicious.
func ValidatePattern(pattern string) error {
// Check pattern length
if len(pattern) > MaxPatternLength {
return &RegexError{
Pattern: truncatePattern(pattern),
Reason: fmt.Sprintf("pattern too long (%d chars, max %d)", len(pattern), MaxPatternLength),
}
}
// Note: Go's regexp package uses Thompson NFA which guarantees O(n) matching time,
// making it inherently resistant to ReDoS attacks. However, we still validate
// pattern length to prevent memory issues during compilation.
return nil
}
// CompileRegex compiles a regex pattern with caching and validation for security.
// Thread-safe: uses RWMutex to prevent race conditions.
// Returns the compiled regex or an error if the pattern is invalid or unsafe.
func CompileRegex(pattern string) (*regexp.Regexp, error) {
// Validate pattern first
if err := ValidatePattern(pattern); err != nil {
return nil, err
}
// Check cache first (read lock)
cacheMu.RLock()
if cached, ok := regexCache[pattern]; ok {
cacheMu.RUnlock()
return cached, nil
}
cacheMu.RUnlock()
// Compile regex outside the lock to avoid holding it during compilation
re, err := regexp.Compile(pattern)
if err != nil {
return nil, &RegexError{
Pattern: truncatePattern(pattern),
Reason: "invalid regex syntax",
Err: err,
}
}
// Write lock to store in cache
cacheMu.Lock()
// Re-check in case another goroutine stored it while we were compiling
if cached, ok := regexCache[pattern]; ok {
cacheMu.Unlock()
return cached, nil
}
// Check cache size and clear if too large
if len(regexCache) >= MaxCacheSize {
regexCache = make(map[string]*regexp.Regexp)
}
regexCache[pattern] = re
cacheMu.Unlock()
return re, nil
}
// CompileRegexUncached compiles a regex pattern without caching.
// Useful for one-off patterns that shouldn't pollute the cache.
func CompileRegexUncached(pattern string) (*regexp.Regexp, error) {
if err := ValidatePattern(pattern); err != nil {
return nil, err
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, &RegexError{
Pattern: truncatePattern(pattern),
Reason: "invalid regex syntax",
Err: err,
}
}
return re, nil
}
// ClearRegexCache clears all cached compiled regular expressions.
// Atomically replaces the map under a single write lock.
func ClearRegexCache() {
cacheMu.Lock()
regexCache = make(map[string]*regexp.Regexp)
cacheMu.Unlock()
}
// CacheStats returns the current number of cached patterns.
func CacheStats() int64 {
cacheMu.RLock()
n := int64(len(regexCache))
cacheMu.RUnlock()
return n
}
// truncatePattern truncates a pattern for display in error messages.
func truncatePattern(pattern string) string {
if len(pattern) > 50 {
return pattern[:47] + "..."
}
return pattern
}
+375
View File
@@ -0,0 +1,375 @@
package util
import (
"errors"
"strings"
"sync"
"testing"
)
func TestValidatePattern(t *testing.T) {
tests := []struct {
name string
pattern string
expectErr bool
}{
{
name: "valid short pattern",
pattern: "^hello.*world$",
expectErr: false,
},
{
name: "valid empty pattern",
pattern: "",
expectErr: false,
},
{
name: "valid pattern at max length",
pattern: strings.Repeat("a", MaxPatternLength),
expectErr: false,
},
{
name: "pattern too long",
pattern: strings.Repeat("a", MaxPatternLength+1),
expectErr: true,
},
{
name: "very long pattern",
pattern: strings.Repeat("x", MaxPatternLength*2),
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePattern(tt.pattern)
if tt.expectErr && err == nil {
t.Error("expected error but got nil")
}
if !tt.expectErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestCompileRegex(t *testing.T) {
// Clear cache before each test
ClearRegexCache()
t.Run("valid pattern is compiled and cached", func(t *testing.T) {
ClearRegexCache()
pattern := "^test.*pattern$"
re1, err := CompileRegex(pattern)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if re1 == nil {
t.Fatal("expected non-nil regex")
}
// Second call should return cached version
re2, err := CompileRegex(pattern)
if err != nil {
t.Fatalf("unexpected error on second call: %v", err)
}
// Should be the same pointer
if re1 != re2 {
t.Error("expected same regex instance from cache")
}
// Cache should have one entry
if stats := CacheStats(); stats != 1 {
t.Errorf("expected cache size 1, got %d", stats)
}
})
t.Run("invalid pattern returns error", func(t *testing.T) {
ClearRegexCache()
pattern := "[invalid(regex"
_, err := CompileRegex(pattern)
if err == nil {
t.Fatal("expected error for invalid regex")
}
var regexErr *RegexError
if !errors.As(err, &regexErr) {
t.Errorf("expected RegexError, got %T", err)
}
})
t.Run("pattern too long returns error", func(t *testing.T) {
ClearRegexCache()
pattern := strings.Repeat("a", MaxPatternLength+1)
_, err := CompileRegex(pattern)
if err == nil {
t.Fatal("expected error for long pattern")
}
var regexErr *RegexError
if !errors.As(err, &regexErr) {
t.Errorf("expected RegexError, got %T", err)
}
})
t.Run("different patterns are cached separately", func(t *testing.T) {
ClearRegexCache()
re1, _ := CompileRegex("pattern1")
re2, _ := CompileRegex("pattern2")
if re1 == re2 {
t.Error("different patterns should produce different regex instances")
}
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2, got %d", stats)
}
})
t.Run("regex matches correctly", func(t *testing.T) {
ClearRegexCache()
re, err := CompileRegex("^hello\\s+world$")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !re.MatchString("hello world") {
t.Error("expected match for 'hello world'")
}
if !re.MatchString("hello world") {
t.Error("expected match for 'hello world'")
}
if re.MatchString("helloworld") {
t.Error("unexpected match for 'helloworld'")
}
})
}
func TestCompileRegexUncached(t *testing.T) {
ClearRegexCache()
t.Run("valid pattern compiles without caching", func(t *testing.T) {
initialSize := CacheStats()
re, err := CompileRegexUncached("^uncached.*pattern$")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if re == nil {
t.Fatal("expected non-nil regex")
}
// Cache size should not change
if stats := CacheStats(); stats != initialSize {
t.Errorf("cache size changed from %d to %d", initialSize, stats)
}
})
t.Run("invalid pattern returns error", func(t *testing.T) {
_, err := CompileRegexUncached("[invalid")
if err == nil {
t.Fatal("expected error for invalid regex")
}
})
t.Run("pattern too long returns error", func(t *testing.T) {
pattern := strings.Repeat("x", MaxPatternLength+1)
_, err := CompileRegexUncached(pattern)
if err == nil {
t.Fatal("expected error for long pattern")
}
})
}
func TestClearRegexCache(t *testing.T) {
// Add some patterns
_, _ = CompileRegex("pattern1")
_, _ = CompileRegex("pattern2")
_, _ = CompileRegex("pattern3")
if stats := CacheStats(); stats < 3 {
t.Fatalf("expected at least 3 cached patterns, got %d", stats)
}
ClearRegexCache()
if stats := CacheStats(); stats != 0 {
t.Errorf("expected cache size 0 after clear, got %d", stats)
}
}
func TestCacheStats(t *testing.T) {
ClearRegexCache()
if stats := CacheStats(); stats != 0 {
t.Errorf("expected initial cache size 0, got %d", stats)
}
_, _ = CompileRegex("a")
if stats := CacheStats(); stats != 1 {
t.Errorf("expected cache size 1, got %d", stats)
}
_, _ = CompileRegex("b")
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2, got %d", stats)
}
// Same pattern should not increase cache size
_, _ = CompileRegex("a")
if stats := CacheStats(); stats != 2 {
t.Errorf("expected cache size 2 after duplicate, got %d", stats)
}
}
func TestConcurrentAccess(t *testing.T) {
ClearRegexCache()
var wg sync.WaitGroup
numGoroutines := 100
numPatterns := 10
// Generate some patterns
patterns := make([]string, numPatterns)
for i := range patterns {
patterns[i] = strings.Repeat("p", i+1)
}
// Concurrent compilation of same patterns
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
pattern := patterns[id%numPatterns]
re, err := CompileRegex(pattern)
if err != nil {
t.Errorf("goroutine %d: unexpected error: %v", id, err)
return
}
if re == nil {
t.Errorf("goroutine %d: nil regex returned", id)
}
}(i)
}
wg.Wait()
// Should have exactly numPatterns cached
if stats := CacheStats(); stats != int64(numPatterns) {
t.Errorf("expected cache size %d, got %d", numPatterns, stats)
}
}
func TestRegexError(t *testing.T) {
t.Run("error message with underlying error", func(t *testing.T) {
underlying := errors.New("underlying error")
err := &RegexError{
Pattern: "test.*",
Reason: "test reason",
Err: underlying,
}
msg := err.Error()
if !strings.Contains(msg, "test.*") {
t.Error("error message should contain pattern")
}
if !strings.Contains(msg, "test reason") {
t.Error("error message should contain reason")
}
if !strings.Contains(msg, "underlying error") {
t.Error("error message should contain underlying error")
}
})
t.Run("error message without underlying error", func(t *testing.T) {
err := &RegexError{
Pattern: "test.*",
Reason: "test reason",
Err: nil,
}
msg := err.Error()
if !strings.Contains(msg, "test.*") {
t.Error("error message should contain pattern")
}
if !strings.Contains(msg, "test reason") {
t.Error("error message should contain reason")
}
})
t.Run("error unwrap", func(t *testing.T) {
underlying := errors.New("underlying")
err := &RegexError{
Pattern: "test",
Reason: "reason",
Err: underlying,
}
if errors.Unwrap(err) != underlying {
t.Error("Unwrap should return underlying error")
}
})
}
func TestTruncatePattern(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "short pattern unchanged",
input: "short",
expected: "short",
},
{
name: "exactly 50 chars unchanged",
input: strings.Repeat("x", 50),
expected: strings.Repeat("x", 50),
},
{
name: "long pattern truncated",
input: strings.Repeat("x", 60),
expected: strings.Repeat("x", 47) + "...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncatePattern(tt.input)
if got != tt.expected {
t.Errorf("truncatePattern() = %q (len %d), want %q (len %d)",
got, len(got), tt.expected, len(tt.expected))
}
})
}
}
// BenchmarkCompileRegex benchmarks regex compilation with caching
func BenchmarkCompileRegex(b *testing.B) {
ClearRegexCache()
pattern := "^test.*pattern\\d+$"
// First call to populate cache
_, _ = CompileRegex(pattern)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = CompileRegex(pattern)
}
}
// BenchmarkCompileRegexUncached benchmarks regex compilation without caching
func BenchmarkCompileRegexUncached(b *testing.B) {
pattern := "^test.*pattern\\d+$"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = CompileRegexUncached(pattern)
}
}
+36
View File
@@ -120,6 +120,42 @@ func (e *StructuredError) WithRemediation(msg string) *StructuredError {
return e
}
// UserMessage returns a user-safe error message without internal details.
// This should be used for API responses to avoid leaking implementation details.
func (e *StructuredError) UserMessage() string {
// Return only the message and remediation, no stack trace, no cause details
if e.Remediation != "" {
return fmt.Sprintf("%s. %s", e.Message, e.Remediation)
}
return e.Message
}
// SanitizeError returns a user-safe error message from any error.
// For StructuredError, it returns UserMessage().
// For other errors, it returns a generic message to avoid leaking internals.
func SanitizeError(err error) string {
if err == nil {
return ""
}
// Check if it's a StructuredError
if se, ok := err.(*StructuredError); ok {
return se.UserMessage()
}
// For other errors, extract only the basic message
// Avoid exposing full paths or implementation details
msg := err.Error()
// Truncate very long messages that might contain stack traces
const maxLen = 200
if len(msg) > maxLen {
msg = msg[:maxLen] + "..."
}
return msg
}
// New creates a new structured error with stack trace.
func New(code ErrorCode, message string) *StructuredError {
return &StructuredError{
+11 -13
View File
@@ -1,6 +1,8 @@
// Package protocol defines shared types used across the MCP file operations server.
package protocol
import "path/filepath"
// Location represents a position in a file.
type Location struct {
File string `json:"file"`
@@ -30,6 +32,8 @@ const (
SymbolProperty SymbolKind = "property"
SymbolModule SymbolKind = "module"
SymbolPackage SymbolKind = "package"
SymbolEnum SymbolKind = "enum"
SymbolTrait SymbolKind = "trait"
)
// Symbol represents a code symbol (function, class, variable, etc.).
@@ -60,12 +64,14 @@ const (
LangVue Language = "vue"
LangJSON Language = "json"
LangYAML Language = "yaml"
LangElixir Language = "elixir"
LangRust Language = "rust"
LangUnknown Language = "unknown"
)
// DetectLanguage detects the language from a filename.
func DetectLanguage(filename string) Language {
ext := getExtension(filename)
ext := filepath.Ext(filename)
switch ext {
case ".go":
return LangGo
@@ -87,19 +93,11 @@ func DetectLanguage(filename string) Language {
return LangJSON
case ".yaml", ".yml":
return LangYAML
case ".ex", ".exs":
return LangElixir
case ".rs":
return LangRust
default:
return LangUnknown
}
}
func getExtension(filename string) string {
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
return filename[i:]
}
if filename[i] == '/' || filename[i] == '\\' {
break
}
}
return ""
}
+4 -23
View File
@@ -29,10 +29,14 @@ func TestDetectLanguage(t *testing.T) {
{"index.html", LangHTML},
{"page.htm", LangHTML},
{"Component.vue", LangVue},
{"app.ex", LangElixir},
{"app_test.exs", LangElixir},
{"mix.exs", LangElixir},
{"unknown.txt", LangUnknown},
{"README", LangUnknown},
{"path/to/file.go", LangGo},
{"path/to/file.ts", LangTypeScript},
{"path/to/file.ex", LangElixir},
}
for _, tt := range tests {
@@ -44,26 +48,3 @@ func TestDetectLanguage(t *testing.T) {
})
}
}
func TestGetExtension(t *testing.T) {
tests := []struct {
filename string
expected string
}{
{"file.go", ".go"},
{"file.test.go", ".go"},
{"path/to/file.ts", ".ts"},
{"noextension", ""},
{".hidden", ".hidden"},
{"file.", "."},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
result := getExtension(tt.filename)
if result != tt.expected {
t.Errorf("getExtension(%q) = %q, want %q", tt.filename, result, tt.expected)
}
})
}
}
+194 -24
View File
@@ -20,15 +20,15 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Functions
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
}
detect_platform() {
@@ -59,10 +59,12 @@ detect_platform() {
;;
esac
# Warn about unsupported combinations
if [ "$os" = "linux" ] && [ "$arch" = "arm64" ]; then
print_error "Linux ARM64 builds are not currently available due to CGO cross-compilation complexity"
print_error "Please build from source: https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
# Intel Macs are no longer receiving macOS updates; we only ship Apple
# Silicon builds. Users on older hardware can build from source.
if [ "$os" = "darwin" ] && [ "$arch" = "amd64" ]; then
print_error "macOS Intel (x86_64) is no longer supported."
print_error "Apple Silicon binaries are available; for Intel, build from source:"
print_error " https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
exit 1
fi
@@ -73,7 +75,7 @@ check_dependencies() {
local missing_deps=()
# Check for required commands
for cmd in curl tar; do
for cmd in curl; do
if ! command -v "$cmd" &> /dev/null; then
missing_deps+=("$cmd")
fi
@@ -86,6 +88,108 @@ check_dependencies() {
fi
}
detect_package_manager() {
if command -v brew &> /dev/null; then
echo "brew"
elif command -v apt-get &> /dev/null; then
echo "apt"
elif command -v yum &> /dev/null; then
echo "yum"
elif command -v pacman &> /dev/null; then
echo "pacman"
elif command -v apk &> /dev/null; then
echo "apk"
else
echo "unknown"
fi
}
install_prerequisites() {
local pkg_mgr="$1"
local install_lsp="${2:-false}"
print_info "Checking prerequisites..."
# Check if ripgrep is installed
if ! command -v rg &> /dev/null; then
print_warn "ripgrep not found - required for file search functionality"
case "$pkg_mgr" in
brew)
print_info "Installing ripgrep via Homebrew..."
brew install ripgrep
;;
apt)
print_info "Installing ripgrep via apt..."
sudo apt-get update && sudo apt-get install -y ripgrep
;;
yum)
print_info "Installing ripgrep via yum..."
sudo yum install -y ripgrep
;;
pacman)
print_info "Installing ripgrep via pacman..."
sudo pacman -S --noconfirm ripgrep
;;
apk)
print_info "Installing ripgrep via apk..."
sudo apk add --no-cache ripgrep
;;
unknown)
print_error "Could not detect package manager"
print_error "Please install ripgrep manually: https://github.com/BurntSushi/ripgrep"
exit 1
;;
esac
else
print_info "✓ ripgrep is already installed"
fi
if [ "$install_lsp" = "true" ]; then
print_info "Installing LSP servers for enhanced IDE features..."
# Install gopls (Go LSP)
if command -v go &> /dev/null && ! command -v gopls &> /dev/null; then
print_info "Installing gopls (Go language server)..."
go install golang.org/x/tools/gopls@latest
fi
# Install typescript-language-server (TypeScript/JavaScript)
if command -v npm &> /dev/null && ! command -v typescript-language-server &> /dev/null; then
print_info "Installing typescript-language-server..."
npm install -g typescript-language-server typescript
fi
# Install pylsp (Python LSP)
if command -v pip3 &> /dev/null && ! python3 -c "import pylsp" 2>/dev/null; then
print_info "Installing python-lsp-server..."
pip3 install python-lsp-server
fi
# Install clangd (C/C++)
if ! command -v clangd &> /dev/null; then
case "$pkg_mgr" in
brew)
print_info "Installing clangd via Homebrew..."
brew install llvm
;;
apt)
print_info "Installing clangd via apt..."
sudo apt-get install -y clangd
;;
yum)
print_info "Installing clangd via yum..."
sudo yum install -y clang-tools-extra
;;
pacman)
print_info "Installing clangd via pacman..."
sudo pacman -S --noconfirm clang
;;
esac
fi
fi
}
get_latest_version() {
local version
version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
@@ -108,14 +212,15 @@ download_and_install() {
print_info "Downloading mcp-filepuff ${version} for ${platform}..."
# Construct download URLs
local archive_name="mcp-filepuff_${version#v}_${platform}.tar.gz"
local archive_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
# Releases ship raw binaries named mcp-filepuff_<version>_<os>_<arch>
# (with .exe on Windows — not handled here).
local asset_name="mcp-filepuff_${version#v}_${platform}"
local asset_url="https://github.com/${REPO}/releases/download/${version}/${asset_name}"
local checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"
# Download archive
if ! curl -sSfL "$archive_url" -o "$tmpdir/$archive_name"; then
print_error "Failed to download $archive_url"
# Download binary
if ! curl -sSfL "$asset_url" -o "$tmpdir/$asset_name"; then
print_error "Failed to download $asset_url"
exit 1
fi
@@ -125,19 +230,36 @@ download_and_install() {
else
print_info "Verifying checksum..."
cd "$tmpdir"
if grep "$archive_name" checksums.txt | sha256sum -c --status; then
print_info "Checksum verification passed"
local expected_checksum
expected_checksum=$(grep "$asset_name" checksums.txt | awk '{print $1}')
if [ -z "$expected_checksum" ]; then
print_warn "Checksum not found for $asset_name, skipping verification"
else
print_error "Checksum verification failed"
exit 1
local actual_checksum
if command -v sha256sum &> /dev/null; then
actual_checksum=$(sha256sum "$asset_name" | awk '{print $1}')
elif command -v shasum &> /dev/null; then
actual_checksum=$(shasum -a 256 "$asset_name" | awk '{print $1}')
else
print_warn "No checksum utility found, skipping verification"
cd - > /dev/null
return
fi
if [ "$expected_checksum" = "$actual_checksum" ]; then
print_info "Checksum verification passed"
else
print_error "Checksum verification failed"
print_error "Expected: $expected_checksum"
print_error "Actual: $actual_checksum"
exit 1
fi
fi
cd - > /dev/null
fi
# Extract archive
print_info "Extracting archive..."
tar -xzf "$tmpdir/$archive_name" -C "$tmpdir"
# Ensure install directory exists
if [ ! -d "$INSTALL_DIR" ]; then
print_info "Creating installation directory: $INSTALL_DIR"
@@ -147,11 +269,11 @@ download_and_install() {
# Install binary
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
if [ -w "$INSTALL_DIR" ]; then
cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
else
print_warn "No write permission to $INSTALL_DIR, trying with sudo..."
sudo cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
sudo cp "$tmpdir/$asset_name" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
fi
@@ -183,6 +305,37 @@ main() {
print_info "MCP Filepuff Installation Script"
echo ""
# Parse command line arguments
local install_lsp="false"
local skip_prereqs="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--with-lsp)
install_lsp="true"
shift
;;
--skip-prereqs)
skip_prereqs="true"
shift
;;
--help)
echo "Usage: install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --with-lsp Install LSP servers (gopls, typescript-language-server, pylsp, clangd)"
echo " --skip-prereqs Skip prerequisite installation (ripgrep, LSP servers)"
echo " --help Show this help message"
exit 0
;;
*)
print_error "Unknown option: $1"
print_error "Use --help for usage information"
exit 1
;;
esac
done
# Check dependencies
check_dependencies
@@ -191,6 +344,18 @@ main() {
platform=$(detect_platform)
print_info "Detected platform: $platform"
# Detect package manager
local pkg_mgr
pkg_mgr=$(detect_package_manager)
print_info "Detected package manager: $pkg_mgr"
echo ""
# Install prerequisites unless skipped
if [ "$skip_prereqs" != "true" ]; then
install_prerequisites "$pkg_mgr" "$install_lsp"
echo ""
fi
# Get latest version
local version
version=$(get_latest_version)
@@ -209,6 +374,11 @@ main() {
echo ""
print_info "To get started, run: $BINARY_NAME --help"
if [ "$install_lsp" != "true" ]; then
echo ""
print_info "Tip: Run with --with-lsp to install LSP servers for enhanced IDE features"
fi
}
# Run main function
+65
View File
@@ -0,0 +1,65 @@
/// A simple point in 2D space.
struct Point {
x: f64,
y: f64,
}
/// Represents different shapes.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}
/// A trait for objects that can be drawn.
trait Drawable {
fn draw(&self);
fn area(&self) -> f64;
}
impl Drawable for Shape {
fn draw(&self) {
println!("Drawing shape");
}
fn area(&self) -> f64 {
0.0
}
}
impl Point {
/// Creates a new point.
fn new(x: f64, y: f64) -> Self {
Point { x, y }
}
fn distance(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
}
/// A type alias for a list of points.
type PointList = Vec<Point>;
/// The maximum number of points allowed.
const MAX_POINTS: usize = 1000;
static ORIGIN: Point = Point { x: 0.0, y: 0.0 };
/// A helper macro for creating points.
macro_rules! point {
($x:expr, $y:expr) => {
Point::new($x, $y)
};
}
mod geometry {
pub fn hello() {
println!("Hello from geometry");
}
}
fn main() {
let p = point!(1.0, 2.0);
println!("Point: ({}, {})", p.x, p.y);
}