Compare commits

...
39 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub eaee0457b3 Update go.mod and go.sum (#23) 2026-05-14 05:21:24 +01:00
lukaszraczyloandGitHub 4c84ffb612 Update go.mod and go.sum (#22) 2026-05-13 05:21:51 +01:00
lukaszraczyloandGitHub 630f1a9c59 Update go.mod and go.sum (#21) 2026-05-09 05:10:57 +01:00
lukaszraczyloandGitHub fab20e3c10 Update go.mod and go.sum (#20) 2026-05-06 05:17:44 +01:00
lukaszraczyloandGitHub f7f6f3d7c1 Update go.mod and go.sum (#19) 2026-05-05 05:05:54 +01:00
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
lukaszraczylo ac1b81b70e fixup! fixup! build(goreleaser): enable CGO and remove Docker builds 2026-01-18 19:38:29 +00:00
lukaszraczylo f216380d37 fixup! build(goreleaser): enable CGO and remove Docker builds 2026-01-18 19:10:41 +00:00
lukaszraczylo 3be03aef7b build(goreleaser): enable CGO and remove Docker builds
- [x] Enable CGO_ENABLED=1 for builds
- [x] Add platform ignore rules for unsupported architectures
- [x] Remove Docker build configuration (dockers_v2)
- [x] Remove Docker signing configuration
- [x] Add platform compatibility check in install script
2026-01-18 19:08:10 +00:00
lukaszraczylo 5800c689e9 build(install): add automated installation script and update README
- [x] Create install.sh script for automated platform detection and installation
- [x] Add checksum verification support in installation script
- [x] Update README with quick install command using curl
- [x] Reorganize installation instructions into quick install and manual sections
- [x] Update binary download URLs to match goreleaser naming convention
- [x] Change installation directory preference from /usr/local/bin to ~/.local/bin
- [x] Remove sudo requirement for installation when possible
2026-01-18 19:05:12 +00:00
72 changed files with 16021 additions and 2732 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Lint
on:
push:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
permissions:
contents: read
jobs:
golangci:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.9.0
args: --timeout=5m
+9 -3
View File
@@ -16,9 +16,15 @@ permissions:
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.24"
docker-enabled: false
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
+33 -29
View File
@@ -5,17 +5,23 @@ 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:
- CGO_ENABLED=0
- CGO_ENABLED=1
flags:
- -trimpath
ldflags:
@@ -30,23 +36,23 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: darwin
goarch: amd64
- 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'
@@ -82,23 +88,19 @@ release:
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
```
dockers_v2:
- images:
- "ghcr.io/lukaszraczylo/filepuff-mcp"
tags:
- "{{ .Version }}"
- "latest"
- "v1"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
labels:
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.title": "{{.ProjectName}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/filepuff-mcp"
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
@@ -116,6 +118,8 @@ docker_signs:
artifacts: manifests
output: true
args:
- sign
- "${artifact}@${digest}"
- "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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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"
+306 -29
View File
@@ -8,39 +8,75 @@ 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
## Installation
### Binary Releases (Recommended)
### Quick Install (Recommended)
Install the latest version with a single command:
```bash
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
```
This script will:
- Automatically detect your platform (OS and architecture)
- Download the latest release
- Verify checksums
- 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-darwin-arm64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/local/bin/
# macOS (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-darwin-amd64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/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-linux-arm64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/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-linux-amd64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/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-windows-amd64.zip" -OutFile mcp-filepuff.zip
Expand-Archive mcp-filepuff.zip -DestinationPath .
Move-Item mcp-filepuff.exe C:\Windows\System32\
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`).
### Prerequisites
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) installed and in PATH
@@ -88,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)
@@ -168,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` | - |
@@ -326,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
@@ -347,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
{
@@ -409,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
@@ -463,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 │
├───────────┬─────────────┬────────────┬─────────────────┤
@@ -478,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
@@ -529,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.54.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.37.0 // indirect
)
+20 -21
View File
@@ -1,24 +1,22 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -26,14 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.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=
@@ -42,15 +40,16 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+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)
}
})
}
}
+385
View File
@@ -0,0 +1,385 @@
#!/bin/bash
# MCP Filepuff Installation Script
# Downloads and installs the latest version of mcp-filepuff
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# GitHub repository information
REPO="lukaszraczylo/filepuff-mcp"
BINARY_NAME="mcp-filepuff"
# Installation directory (will try ~/.local/bin first, then /usr/local/bin)
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Functions
print_info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
print_warn() {
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
print_error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
}
detect_platform() {
local os
local arch
# Detect OS
case "$(uname -s)" in
Linux*) os="linux" ;;
Darwin*) os="darwin" ;;
MINGW*|MSYS*|CYGWIN*)
print_error "Windows is not directly supported. Please use WSL2."
exit 1
;;
*)
print_error "Unsupported operating system: $(uname -s)"
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
print_error "Unsupported architecture: $(uname -m)"
exit 1
;;
esac
# 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
echo "${os}_${arch}"
}
check_dependencies() {
local missing_deps=()
# Check for required commands
for cmd in curl; do
if ! command -v "$cmd" &> /dev/null; then
missing_deps+=("$cmd")
fi
done
if [ ${#missing_deps[@]} -ne 0 ]; then
print_error "Missing required dependencies: ${missing_deps[*]}"
print_error "Please install them and try again."
exit 1
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)
if [ -z "$version" ]; then
print_error "Failed to fetch latest version from GitHub"
exit 1
fi
echo "$version"
}
download_and_install() {
local version="$1"
local platform="$2"
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
print_info "Downloading mcp-filepuff ${version} for ${platform}..."
# 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 binary
if ! curl -sSfL "$asset_url" -o "$tmpdir/$asset_name"; then
print_error "Failed to download $asset_url"
exit 1
fi
# Download checksums
if ! curl -sSfL "$checksum_url" -o "$tmpdir/checksums.txt"; then
print_warn "Failed to download checksums, skipping verification"
else
print_info "Verifying checksum..."
cd "$tmpdir"
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
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
# Ensure install directory exists
if [ ! -d "$INSTALL_DIR" ]; then
print_info "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
fi
# Install binary
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
if [ -w "$INSTALL_DIR" ]; then
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/$asset_name" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
fi
print_info "Installation complete!"
}
check_path() {
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
print_warn "$INSTALL_DIR is not in your PATH"
print_warn "Add it to your PATH by adding this line to your shell profile:"
echo ""
echo " export PATH=\"\$PATH:$INSTALL_DIR\""
echo ""
fi
}
verify_installation() {
if command -v "$BINARY_NAME" &> /dev/null; then
local installed_version
installed_version=$("$BINARY_NAME" -version 2>&1 || echo "unknown")
print_info "Successfully installed: $installed_version"
else
print_warn "Binary installed but not found in PATH"
print_info "You can run it directly: $INSTALL_DIR/$BINARY_NAME"
fi
}
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
# Detect platform
local platform
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)
print_info "Latest version: $version"
echo ""
# Download and install
download_and_install "$version" "$platform"
echo ""
# Check PATH
check_path
# Verify installation
verify_installation
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
main "$@"
+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);
}