mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-14 05:30:19 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15340cdefc | ||
|
|
835f03f53c | ||
|
|
5ad975ee7a | ||
|
|
b131c1edd3 | ||
|
|
7829ae4891 | ||
|
|
0d91e94416 | ||
|
|
7ad20c33f9 | ||
|
|
90244fda67 | ||
|
|
9a9d81f2a4 | ||
|
|
78c5fc7236 | ||
|
|
ccfbdc513f | ||
|
|
db803c6950 | ||
|
|
34d4401280 | ||
|
|
267378181c | ||
|
|
2b83fccb35 | ||
|
|
996be39314 | ||
|
|
50da24756f | ||
|
|
a778e0b28b | ||
|
|
e2dc4a1f40 | ||
|
|
982c2c8b44 | ||
|
|
6980d3b294 | ||
|
|
9205b2bc26 | ||
|
|
143a166249 |
@@ -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@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout=5m
|
||||||
@@ -18,6 +18,14 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version: "1.25"
|
||||||
rolling-release-tag: "v1"
|
rolling-release-tag: "v1"
|
||||||
|
platforms: >-
|
||||||
|
[
|
||||||
|
{"os":"macos-13","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"},
|
||||||
|
{"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
|
secrets: inherit
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
TODO.md
|
TODO.md
|
||||||
bin/mcp-filepuff
|
bin/mcp-filepuff
|
||||||
mcp-filepuff
|
mcp-filepuff
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -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
|
||||||
+23
-5
@@ -11,7 +11,7 @@ before:
|
|||||||
- go generate ./...
|
- go generate ./...
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: mcp-filepuff
|
- id: mcp-filepuff-cgo
|
||||||
main: ./cmd/mcp-filepuff
|
main: ./cmd/mcp-filepuff
|
||||||
binary: mcp-filepuff
|
binary: mcp-filepuff
|
||||||
env:
|
env:
|
||||||
@@ -31,10 +31,6 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
ignore:
|
ignore:
|
||||||
- goos: darwin
|
|
||||||
goarch: amd64
|
|
||||||
- goos: linux
|
|
||||||
goarch: arm64
|
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
|
||||||
@@ -90,6 +86,19 @@ release:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- image_templates:
|
||||||
|
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
|
||||||
|
- "ghcr.io/lukaszraczylo/filepuff-mcp:latest"
|
||||||
|
ids:
|
||||||
|
- mcp-filepuff-cgo
|
||||||
|
use: buildx
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
goarch: amd64
|
||||||
|
goos: linux
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
signs:
|
signs:
|
||||||
- cmd: cosign
|
- cmd: cosign
|
||||||
signature: "${artifact}.sigstore.json"
|
signature: "${artifact}.sigstore.json"
|
||||||
@@ -101,4 +110,13 @@ signs:
|
|||||||
artifacts: checksum
|
artifacts: checksum
|
||||||
output: true
|
output: true
|
||||||
|
|
||||||
|
docker_signs:
|
||||||
|
- cmd: cosign
|
||||||
|
artifacts: manifests
|
||||||
|
output: true
|
||||||
|
args:
|
||||||
|
- "sign"
|
||||||
|
- "--yes"
|
||||||
|
- "${artifact}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM gcr.io/distroless/base-debian12:nonroot
|
||||||
|
COPY mcp-filepuff /usr/local/bin/mcp-filepuff
|
||||||
|
ENTRYPOINT ["mcp-filepuff"]
|
||||||
@@ -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
|
||||||
BINARY_NAME=mcp-filepuff
|
BINARY_NAME=mcp-filepuff
|
||||||
# Build directory
|
# Build directory
|
||||||
@@ -42,12 +41,29 @@ build-all:
|
|||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
test:
|
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
|
# Run tests with short flag
|
||||||
test-short:
|
test-short:
|
||||||
$(GOTEST) -v -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 build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
@@ -72,9 +88,18 @@ help:
|
|||||||
@echo " build - Build the binary"
|
@echo " build - Build the binary"
|
||||||
@echo " build-all - Build for all platforms"
|
@echo " build-all - Build for all platforms"
|
||||||
@echo " test - Run tests with coverage"
|
@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 " test-short - Run short tests"
|
||||||
@echo " lint - Run linters"
|
@echo " lint - Run linters"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo " install - Install binary to GOPATH/bin"
|
@echo " install - Install binary to GOPATH/bin"
|
||||||
@echo " run - Build and run the server"
|
@echo " run - Build and run the server"
|
||||||
@echo " run-workspace - Run with specific workspace (WORKSPACE=/path)"
|
@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"
|
||||||
@@ -8,7 +8,7 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
|
|||||||
- **AST-Aware File Reading**: Read files with symbol extraction using Tree-sitter
|
- **AST-Aware File Reading**: Read files with symbol extraction using Tree-sitter
|
||||||
- **Code Pattern Matching**: Query code using patterns with capture placeholders
|
- **Code Pattern Matching**: Query code using patterns with capture placeholders
|
||||||
- **LSP Integration**: Go-to-definition, find references, and symbol info via language servers
|
- **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
|
- **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
|
- **Token Efficient**: Optimized for minimal token usage with symbols-only mode and output limiting
|
||||||
|
|
||||||
@@ -29,6 +29,30 @@ This script will:
|
|||||||
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
|
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
|
||||||
- Make the binary executable
|
- 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
|
### Manual Installation
|
||||||
|
|
||||||
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
|
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
|
||||||
@@ -104,6 +128,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.
|
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
|
## Usage
|
||||||
|
|
||||||
### Running the Server (Standalone)
|
### Running the Server (Standalone)
|
||||||
@@ -184,7 +241,7 @@ When performing file operations, prefer filepuff MCP tools over built-in equival
|
|||||||
| Read files | `mcp__filepuff__file_read` | Read |
|
| Read files | `mcp__filepuff__file_read` | Read |
|
||||||
| Search content | `mcp__filepuff__file_search` | Grep |
|
| Search content | `mcp__filepuff__file_search` | Grep |
|
||||||
| AST pattern search | `mcp__filepuff__ast_query` | Grep/Glob |
|
| 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 definitions | `mcp__filepuff__find_definition` | Grep |
|
||||||
| Find references | `mcp__filepuff__find_references` | Grep |
|
| Find references | `mcp__filepuff__find_references` | Grep |
|
||||||
| Symbol info | `mcp__filepuff__symbol_at` | - |
|
| Symbol info | `mcp__filepuff__symbol_at` | - |
|
||||||
@@ -342,8 +399,8 @@ Find all references to the symbol at a specific position.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `edit_preview`
|
### `edit_apply`
|
||||||
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.).
|
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
- `file` (required): Path to the file to edit
|
- `file` (required): Path to the file to edit
|
||||||
@@ -363,13 +420,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_text`: Exact text to match (must be unique or use selector_index)
|
||||||
- `selector_pattern`: Regex pattern to match
|
- `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)**:
|
**Example (AST mode - Go file)**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -480,13 +530,14 @@ make clean
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### High-Level Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ MCP Server │
|
│ MCP Server │
|
||||||
├─────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────┤
|
||||||
│ Tools: file_search, file_read, ast_query, symbol_at, │
|
│ Tools: file_search, file_read, ast_query, symbol_at, │
|
||||||
│ find_definition, find_references, │
|
│ find_definition, find_references, edit_apply, ping │
|
||||||
│ edit_preview, edit_apply, ping │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────┤
|
||||||
│ Core Engines │
|
│ Core Engines │
|
||||||
├───────────┬─────────────┬────────────┬─────────────────┤
|
├───────────┬─────────────┬────────────┬─────────────────┤
|
||||||
@@ -495,6 +546,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
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
@@ -546,7 +811,6 @@ The edit engine validates syntax before and after edits.
|
|||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
- Ensure `new_content` is syntactically valid for the target language
|
- 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
|
- Check that the selector matches exactly one node
|
||||||
|
|
||||||
#### Timeout Errors
|
#### Timeout Errors
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -67,9 +67,11 @@ func setupLogger(level string, logFile string) *slog.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
|
var logFileErr error
|
||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logFileErr = err
|
||||||
// Fallback to stderr
|
// Fallback to stderr
|
||||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||||
} else {
|
} else {
|
||||||
@@ -80,5 +82,15 @@ func setupLogger(level string, logFile string) *slog.Logger {
|
|||||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+1488
-1262
File diff suppressed because it is too large
Load Diff
+345
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -4,21 +4,17 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0
|
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/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/mark3labs/mcp-go v0.43.2
|
github.com/mark3labs/mcp-go v0.48.0
|
||||||
github.com/sergi/go-diff v1.4.0
|
github.com/sergi/go-diff v1.4.0
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // 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/spf13/cast v1.10.0 // 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
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -9,16 +5,16 @@ 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -26,10 +22,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
@@ -44,8 +38,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+86
-28
@@ -2,6 +2,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,40 +13,46 @@ import (
|
|||||||
|
|
||||||
// Config holds all configuration options for the MCP server.
|
// Config holds all configuration options for the MCP server.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Formatters map[string]string `json:"formatters"`
|
Formatters map[string]string `json:"formatters"`
|
||||||
WorkspaceRoot string `json:"workspace_root"`
|
WorkspaceRoot string `json:"workspace_root"`
|
||||||
LSPTimeout time.Duration `json:"lsp_timeout"`
|
LSPTimeout time.Duration `json:"lsp_timeout"`
|
||||||
SearchTimeout time.Duration `json:"search_timeout"`
|
SearchTimeout time.Duration `json:"search_timeout"`
|
||||||
MaxFileSize int64 `json:"max_file_size"`
|
MaxFileSize int64 `json:"max_file_size"`
|
||||||
MaxSearchResults int `json:"max_search_results"`
|
MaxParseSize int64 `json:"max_parse_size"`
|
||||||
MaxEditSize int64 `json:"max_edit_size"`
|
MaxSearchResults int `json:"max_search_results"`
|
||||||
EnableLSP bool `json:"enable_lsp"`
|
MaxEditSize int64 `json:"max_edit_size"`
|
||||||
FollowSymlinks bool `json:"follow_symlinks"`
|
EnableLSP bool `json:"enable_lsp"`
|
||||||
RespectGitignore bool `json:"respect_gitignore"`
|
FollowSymlinks bool `json:"follow_symlinks"`
|
||||||
|
RespectGitignore bool `json:"respect_gitignore"`
|
||||||
|
ResourceLinkThresholdBytes int `json:"resource_link_threshold_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default values for configuration.
|
// Default values for configuration.
|
||||||
const (
|
const (
|
||||||
DefaultLSPTimeout = 5 * time.Minute
|
DefaultLSPTimeout = 5 * time.Minute
|
||||||
DefaultSearchTimeout = 30 * time.Second
|
DefaultSearchTimeout = 30 * time.Second
|
||||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||||
DefaultMaxSearchResults = 1000
|
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
||||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
DefaultMaxSearchResults = 1000
|
||||||
|
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||||
|
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default returns a Config with default values.
|
// Default returns a Config with default values.
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
WorkspaceRoot: ".",
|
WorkspaceRoot: ".",
|
||||||
LSPTimeout: DefaultLSPTimeout,
|
LSPTimeout: DefaultLSPTimeout,
|
||||||
SearchTimeout: DefaultSearchTimeout,
|
SearchTimeout: DefaultSearchTimeout,
|
||||||
MaxFileSize: DefaultMaxFileSize,
|
MaxFileSize: DefaultMaxFileSize,
|
||||||
MaxSearchResults: DefaultMaxSearchResults,
|
MaxParseSize: DefaultMaxParseSize,
|
||||||
MaxEditSize: DefaultMaxEditSize,
|
MaxSearchResults: DefaultMaxSearchResults,
|
||||||
EnableLSP: true,
|
MaxEditSize: DefaultMaxEditSize,
|
||||||
Formatters: make(map[string]string),
|
EnableLSP: true,
|
||||||
FollowSymlinks: true,
|
Formatters: make(map[string]string),
|
||||||
RespectGitignore: true,
|
FollowSymlinks: true,
|
||||||
|
RespectGitignore: true,
|
||||||
|
ResourceLinkThresholdBytes: DefaultResourceLinkThresholdBytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +72,26 @@ func Load(workspaceRoot string) (*Config, error) {
|
|||||||
cfg.WorkspaceRoot = cwd
|
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")
|
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
|
||||||
if data, err := os.ReadFile(configPath); err == nil {
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
if err := json.Unmarshal(data, cfg); err != nil {
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, err
|
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
|
// Override from environment variables
|
||||||
cfg.loadFromEnv()
|
cfg.loadFromEnv()
|
||||||
@@ -169,6 +189,44 @@ func (c *Config) IsPathAllowed(path string) bool {
|
|||||||
|
|
||||||
// Check if the path is within workspace (doesn't start with ..)
|
// Check if the path is within workspace (doesn't start with ..)
|
||||||
// This prevents both "../" attacks and symlink bypasses
|
// This prevents both "../" attacks and symlink bypasses
|
||||||
// Also reject empty relative path (which means it's the workspace root itself)
|
// The workspace root itself (rel == ".") is a valid, allowed path
|
||||||
return rel != "." && !strings.HasPrefix(rel, "..")
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestLoad(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
|
||||||
cfg, err := Load(tmpDir)
|
cfg, err := Load(tmpDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,7 +108,7 @@ func TestIsPathAllowed(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.WorkspaceRoot = tmpDir
|
cfg.WorkspaceRoot = tmpDir
|
||||||
@@ -156,7 +156,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
|
||||||
// Write config file
|
// Write config file
|
||||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||||
@@ -164,7 +164,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
|||||||
"enable_lsp": false,
|
"enable_lsp": false,
|
||||||
"follow_symlinks": false
|
"follow_symlinks": false
|
||||||
}`
|
}`
|
||||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
err = os.WriteFile(configPath, []byte(configContent), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to write config file: %v", err)
|
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")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func TestIsPathAllowed_BasicCases(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "workspace root itself",
|
name: "workspace root itself",
|
||||||
path: tmpDir,
|
path: tmpDir,
|
||||||
expected: false, // Empty relative path
|
expected: true, // Workspace root is a valid, allowed path (needed for ast_query)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-142
@@ -6,37 +6,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
"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/errors"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
"github.com/sergi/go-diff/diffmatchpatch"
|
"github.com/sergi/go-diff/diffmatchpatch"
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
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.
|
// EditOperation defines the type of edit operation.
|
||||||
type EditOperation string
|
type EditOperation string
|
||||||
|
|
||||||
@@ -80,6 +61,7 @@ type EditResult struct {
|
|||||||
// Engine performs AST-aware edits.
|
// Engine performs AST-aware edits.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
registry *parser.Registry
|
registry *parser.Registry
|
||||||
|
dmp *diffmatchpatch.DiffMatchPatch
|
||||||
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +69,7 @@ type Engine struct {
|
|||||||
func NewEngine(registry *parser.Registry) *Engine {
|
func NewEngine(registry *parser.Registry) *Engine {
|
||||||
return &Engine{
|
return &Engine{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
|
dmp: diffmatchpatch.New(),
|
||||||
fileLocks: sync.Map{},
|
fileLocks: sync.Map{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,19 +169,24 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate diff
|
// Generate diff
|
||||||
diff := generateDiff(string(content), string(newContent), edit.File)
|
diff := e.generateDiff(string(content), string(newContent), edit.File)
|
||||||
|
|
||||||
result := &EditResult{
|
result := &EditResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
Diff: diff,
|
Diff: diff,
|
||||||
OriginalContent: string(content),
|
Applied: false,
|
||||||
NewContent: string(newContent),
|
|
||||||
Applied: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply changes if requested
|
// Apply changes if requested
|
||||||
if apply {
|
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)
|
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||||
return &EditResult{
|
return &EditResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -238,19 +226,24 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate diff
|
// Generate diff
|
||||||
diff := generateDiff(string(content), string(newContent), edit.File)
|
diff := e.generateDiff(string(content), string(newContent), edit.File)
|
||||||
|
|
||||||
result := &EditResult{
|
result := &EditResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
Diff: diff,
|
Diff: diff,
|
||||||
OriginalContent: string(content),
|
Applied: false,
|
||||||
NewContent: string(newContent),
|
|
||||||
Applied: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply changes if requested
|
// Apply changes if requested
|
||||||
if apply {
|
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)
|
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||||
return &EditResult{
|
return &EditResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -319,7 +312,7 @@ func (e *Engine) validateTextEdit(edit *ASTEdit) error {
|
|||||||
|
|
||||||
// Validate regex pattern if provided (uses cached compilation)
|
// Validate regex pattern if provided (uses cached compilation)
|
||||||
if edit.Selector.TextPattern != "" {
|
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)
|
return errors.Wrap(errors.ErrInvalidEdit, "invalid text_pattern regex", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,17 +368,18 @@ func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by specificity: named nodes first, then by size (smallest first)
|
|
||||||
result := make([]*sitter.Node, len(nodes))
|
result := make([]*sitter.Node, len(nodes))
|
||||||
copy(result, nodes)
|
copy(result, nodes)
|
||||||
|
|
||||||
for i := 0; i < len(result)-1; i++ {
|
slices.SortFunc(result, func(a, b *sitter.Node) int {
|
||||||
for j := i + 1; j < len(result); j++ {
|
if shouldPrefer(a, b) {
|
||||||
if shouldPrefer(result[j], result[i]) {
|
return -1
|
||||||
result[i], result[j] = result[j], result[i]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if shouldPrefer(b, a) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -488,13 +482,12 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyEdit applies the edit operation to the content.
|
// 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) {
|
func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
|
||||||
startByte := node.StartByte()
|
startByte := node.StartByte()
|
||||||
endByte := node.EndByte()
|
endByte := node.EndByte()
|
||||||
|
|
||||||
// Detect and preserve indentation
|
newContent := edit.NewContent
|
||||||
indentation := detectIndentation(content, startByte)
|
|
||||||
newContent := indentContent(edit.NewContent, indentation)
|
|
||||||
|
|
||||||
var result []byte
|
var result []byte
|
||||||
|
|
||||||
@@ -502,18 +495,34 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]
|
|||||||
case EditReplace:
|
case EditReplace:
|
||||||
result = append(result, content[:startByte]...)
|
result = append(result, content[:startByte]...)
|
||||||
result = append(result, []byte(newContent)...)
|
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:]...)
|
result = append(result, content[endByte:]...)
|
||||||
|
|
||||||
case EditInsertBefore:
|
case EditInsertBefore:
|
||||||
|
insertion := newContent
|
||||||
|
if !strings.HasSuffix(insertion, "\n") {
|
||||||
|
insertion += "\n"
|
||||||
|
}
|
||||||
result = append(result, content[:startByte]...)
|
result = append(result, content[:startByte]...)
|
||||||
result = append(result, []byte(newContent)...)
|
result = append(result, []byte(insertion)...)
|
||||||
result = append(result, '\n')
|
|
||||||
result = append(result, content[startByte:]...)
|
result = append(result, content[startByte:]...)
|
||||||
|
|
||||||
case EditInsertAfter:
|
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, content[:endByte]...)
|
||||||
result = append(result, '\n')
|
result = append(result, []byte(insertion)...)
|
||||||
result = append(result, []byte(newContent)...)
|
|
||||||
result = append(result, content[endByte:]...)
|
result = append(result, content[endByte:]...)
|
||||||
|
|
||||||
case EditDelete:
|
case EditDelete:
|
||||||
@@ -528,16 +537,16 @@ func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectIndentation detects the indentation at a given byte position.
|
// 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
|
// Find the start of the line
|
||||||
lineStart := int(bytePos)
|
lineStart := bytePos
|
||||||
for lineStart > 0 && content[lineStart-1] != '\n' {
|
for lineStart > 0 && content[lineStart-1] != '\n' {
|
||||||
lineStart--
|
lineStart--
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract leading whitespace
|
// Extract leading whitespace
|
||||||
var indent strings.Builder
|
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]
|
c := content[i]
|
||||||
if c == ' ' || c == '\t' {
|
if c == ' ' || c == '\t' {
|
||||||
indent.WriteByte(c)
|
indent.WriteByte(c)
|
||||||
@@ -565,38 +574,140 @@ func indentContent(content string, indent string) string {
|
|||||||
return strings.Join(lines, "\n")
|
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.
|
// generateDiff creates a unified diff between original and modified content.
|
||||||
// Uses Myers diff algorithm for accurate and readable diffs.
|
// Uses line-level Myers diff algorithm and outputs a proper unified diff
|
||||||
func generateDiff(original, modified, filename string) string {
|
// with context lines (3 before/after each change, merging close hunks).
|
||||||
dmp := diffmatchpatch.New()
|
func (e *Engine) generateDiff(original, modified, filename string) string {
|
||||||
diffs := dmp.DiffMain(original, modified, false)
|
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
|
// Cleanup for readability
|
||||||
diffs = dmp.DiffCleanupSemantic(diffs)
|
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
|
var buf bytes.Buffer
|
||||||
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
|
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
|
||||||
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
|
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
|
||||||
|
|
||||||
// Group diffs into hunks
|
for _, r := range ranges {
|
||||||
lineNum := 1
|
// Determine hunk header line numbers
|
||||||
for _, diff := range diffs {
|
var oldStart, oldCount, newStart, newCount int
|
||||||
lines := strings.Split(diff.Text, "\n")
|
for i := r.start; i <= r.end; i++ {
|
||||||
for i, line := range lines {
|
l := lines[i]
|
||||||
// Skip empty last line from split
|
switch l.op {
|
||||||
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))
|
|
||||||
case diffmatchpatch.DiffEqual:
|
case diffmatchpatch.DiffEqual:
|
||||||
buf.WriteString(fmt.Sprintf(" %s\n", line))
|
if oldCount == 0 {
|
||||||
lineNum++
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,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)))
|
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) {
|
if index >= len(matches) {
|
||||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
||||||
}
|
}
|
||||||
@@ -672,7 +765,7 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
|
|||||||
|
|
||||||
// findRegexPattern finds a regex pattern match in content.
|
// findRegexPattern finds a regex pattern match in content.
|
||||||
func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (start, end int, err error) {
|
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 {
|
if err != nil {
|
||||||
return 0, 0, errors.Wrap(errors.ErrInvalidEdit, "invalid regex pattern", err)
|
return 0, 0, errors.Wrap(errors.ErrInvalidEdit, "invalid regex pattern", err)
|
||||||
}
|
}
|
||||||
@@ -682,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)))
|
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) {
|
if index >= len(matches) {
|
||||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
||||||
}
|
}
|
||||||
@@ -719,6 +797,10 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines := bytes.Split(content, []byte("\n"))
|
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)
|
totalLines := len(lines)
|
||||||
|
|
||||||
// Convert to 0-indexed
|
// Convert to 0-indexed
|
||||||
@@ -734,7 +816,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
|||||||
|
|
||||||
// Calculate byte positions
|
// Calculate byte positions
|
||||||
start = 0
|
start = 0
|
||||||
for i := 0; i < startIdx; i++ {
|
for i := range startIdx {
|
||||||
start += len(lines[i]) + 1 // +1 for newline
|
start += len(lines[i]) + 1 // +1 for newline
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +834,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
|||||||
// applyTextEditOperation applies a text edit operation.
|
// applyTextEditOperation applies a text edit operation.
|
||||||
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
|
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
|
||||||
// Detect indentation at the selection point
|
// Detect indentation at the selection point
|
||||||
indentation := detectIndentationAtByte(content, start)
|
indentation := detectIndentation(content, start)
|
||||||
indentedContent := indentContent(newContent, indentation)
|
indentedContent := indentContent(newContent, indentation)
|
||||||
|
|
||||||
var result []byte
|
var result []byte
|
||||||
@@ -761,18 +843,34 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start,
|
|||||||
case EditReplace:
|
case EditReplace:
|
||||||
result = append(result, content[:start]...)
|
result = append(result, content[:start]...)
|
||||||
result = append(result, []byte(indentedContent)...)
|
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:]...)
|
result = append(result, content[end:]...)
|
||||||
|
|
||||||
case EditInsertBefore:
|
case EditInsertBefore:
|
||||||
|
insertion := indentedContent
|
||||||
|
if !strings.HasSuffix(insertion, "\n") {
|
||||||
|
insertion += "\n"
|
||||||
|
}
|
||||||
result = append(result, content[:start]...)
|
result = append(result, content[:start]...)
|
||||||
result = append(result, []byte(indentedContent)...)
|
result = append(result, []byte(insertion)...)
|
||||||
result = append(result, '\n')
|
|
||||||
result = append(result, content[start:]...)
|
result = append(result, content[start:]...)
|
||||||
|
|
||||||
case EditInsertAfter:
|
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, content[:end]...)
|
||||||
result = append(result, '\n')
|
result = append(result, []byte(insertion)...)
|
||||||
result = append(result, []byte(indentedContent)...)
|
|
||||||
result = append(result, content[end:]...)
|
result = append(result, content[end:]...)
|
||||||
|
|
||||||
case EditDelete:
|
case EditDelete:
|
||||||
@@ -786,28 +884,6 @@ func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start,
|
|||||||
return result, nil
|
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.
|
// truncateString truncates a string to maxLen with ellipsis.
|
||||||
func truncateString(s string, maxLen int) string {
|
func truncateString(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
@@ -816,11 +892,6 @@ func truncateString(s string, maxLen int) string {
|
|||||||
return s[:maxLen-3] + "..."
|
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.
|
// ValidateLanguage checks if AST editing is supported for a file.
|
||||||
// Returns nil for supported languages, error for unsupported.
|
// Returns nil for supported languages, error for unsupported.
|
||||||
// Note: Text-based editing is always available regardless of this check.
|
// Note: Text-based editing is always available regardless of this check.
|
||||||
|
|||||||
+389
-8
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||||
|
"github.com/sergi/go-diff/diffmatchpatch"
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
sitter "github.com/smacker/go-tree-sitter"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,7 +358,7 @@ func TestDetectIndentation(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
want string
|
want string
|
||||||
pos uint32
|
pos int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no indent",
|
name: "no indent",
|
||||||
@@ -394,7 +395,7 @@ func TestGenerateDiff(t *testing.T) {
|
|||||||
modified := "line1\nmodified\nline3"
|
modified := "line1\nmodified\nline3"
|
||||||
filename := "test.txt"
|
filename := "test.txt"
|
||||||
|
|
||||||
diff := generateDiff(original, modified, filename)
|
diff := (&Engine{dmp: diffmatchpatch.New()}).generateDiff(original, modified, filename)
|
||||||
|
|
||||||
if !strings.Contains(diff, "---") {
|
if !strings.Contains(diff, "---") {
|
||||||
t.Error("diff should contain --- header")
|
t.Error("diff should contain --- header")
|
||||||
@@ -410,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 ====================
|
// ==================== Text-based editing tests ====================
|
||||||
|
|
||||||
func TestTextEditWithExactText(t *testing.T) {
|
func TestTextEditWithExactText(t *testing.T) {
|
||||||
@@ -593,7 +659,7 @@ SECRET_KEY=abc123
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTextEditMultipleMatchesError(t *testing.T) {
|
func TestTextEditMultipleMatchesSelectsFirst(t *testing.T) {
|
||||||
registry := parser.NewRegistry()
|
registry := parser.NewRegistry()
|
||||||
defer registry.Close()
|
defer registry.Close()
|
||||||
e := NewEngine(registry)
|
e := NewEngine(registry)
|
||||||
@@ -624,12 +690,19 @@ more code
|
|||||||
t.Fatalf("apply failed: %v", err)
|
t.Fatalf("apply failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should fail because of multiple matches
|
// Index 0 (default) should select the first match
|
||||||
if result.Success {
|
if !result.Success {
|
||||||
t.Error("expected error for multiple matches without index")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,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
@@ -16,6 +16,12 @@ import (
|
|||||||
json "github.com/goccy/go-json"
|
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.
|
// Client represents an LSP client connection.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
@@ -104,12 +110,33 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
|
|||||||
notifications: make(chan *Notification, 100),
|
notifications: make(chan *Notification, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start reader goroutine
|
// Start reader goroutine for stdout
|
||||||
go c.readLoop()
|
go c.readLoop()
|
||||||
|
|
||||||
|
// Start stderr drain goroutine to prevent pipe buffer from filling up
|
||||||
|
go c.drainStderr()
|
||||||
|
|
||||||
return c, nil
|
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.
|
// Call sends a request and waits for a response.
|
||||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||||
c.runningMu.RLock()
|
c.runningMu.RLock()
|
||||||
@@ -208,7 +235,7 @@ func (c *Client) Close() error {
|
|||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
// Clean exit
|
// Clean exit
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(ProcessKillTimeout):
|
||||||
// Force kill
|
// Force kill
|
||||||
_ = c.cmd.Process.Kill()
|
_ = c.cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
@@ -241,7 +268,11 @@ func (c *Client) send(msg interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readLoop reads and dispatches messages from the server.
|
// 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() {
|
func (c *Client) readLoop() {
|
||||||
|
defer c.drainPending()
|
||||||
|
|
||||||
reader := bufio.NewReader(c.stdout)
|
reader := bufio.NewReader(c.stdout)
|
||||||
|
|
||||||
for {
|
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.
|
// IsRunning returns whether the client is running.
|
||||||
func (c *Client) IsRunning() bool {
|
func (c *Client) IsRunning() bool {
|
||||||
c.runningMu.RLock()
|
c.runningMu.RLock()
|
||||||
|
|||||||
+115
-37
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,7 +16,22 @@ import (
|
|||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"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.
|
// 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 {
|
type Manager struct {
|
||||||
servers map[protocol.Language]*ManagedServer
|
servers map[protocol.Language]*ManagedServer
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -24,6 +40,7 @@ type Manager struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
idleTimeout time.Duration
|
idleTimeout time.Duration
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
closeOnce sync.Once
|
||||||
stopped bool
|
stopped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,14 +85,32 @@ var DefaultServerConfigs = map[protocol.Language]ServerConfig{
|
|||||||
protocol.LangCpp: {
|
protocol.LangCpp: {
|
||||||
Command: []string{"clangd"},
|
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.
|
// NewManager creates a new LSP manager.
|
||||||
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
|
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
servers: make(map[protocol.Language]*ManagedServer),
|
servers: make(map[protocol.Language]*ManagedServer),
|
||||||
timeout: 10 * time.Second,
|
timeout: DefaultLSPTimeout,
|
||||||
idleTimeout: 5 * time.Minute,
|
idleTimeout: DefaultIdleTimeout,
|
||||||
workspaceRoot: workspaceRoot,
|
workspaceRoot: workspaceRoot,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
stopReaper: make(chan struct{}),
|
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])
|
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...)
|
args := append(config.Command[1:], config.Args...)
|
||||||
cmd := exec.CommandContext(ctx, cmdPath, args...)
|
cmd := exec.Command(cmdPath, args...)
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
cmd.Dir = m.workspaceRoot
|
cmd.Dir = m.workspaceRoot
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
client, err := NewClient(cmd)
|
client, err := NewClient(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,13 +197,20 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
|||||||
openDocs: make(map[string]int),
|
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
|
// Initialize server
|
||||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
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
|
newSrv.initErr = err
|
||||||
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
||||||
WithContext("language", string(lang)).
|
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")
|
WithRemediation("Check LSP server logs for initialization errors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as successfully initialized to prevent cleanup
|
||||||
|
initialized = true
|
||||||
|
|
||||||
newSrv.ready = true
|
newSrv.ready = true
|
||||||
m.servers[lang] = newSrv
|
m.servers[lang] = newSrv
|
||||||
m.logger.Info("started LSP server", "language", lang, "command", config.Command[0])
|
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.
|
// 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)
|
uri := fileToURI(file)
|
||||||
|
|
||||||
|
// Quick check under lock — common fast path.
|
||||||
srv.mu.Lock()
|
srv.mu.Lock()
|
||||||
if _, ok := srv.openDocs[uri]; ok {
|
if _, ok := srv.openDocs[uri]; ok {
|
||||||
srv.mu.Unlock()
|
srv.mu.Unlock()
|
||||||
@@ -377,15 +435,23 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
|
|||||||
}
|
}
|
||||||
srv.mu.Unlock()
|
srv.mu.Unlock()
|
||||||
|
|
||||||
// Read file content
|
// Read file content outside the lock to avoid holding it during I/O.
|
||||||
content, err := os.ReadFile(file)
|
content, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read file: %w", err)
|
return fmt.Errorf("failed to read file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get language ID
|
|
||||||
langID := languageToLSPID(srv.language)
|
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{
|
params := DidOpenTextDocumentParams{
|
||||||
TextDocument: TextDocumentItem{
|
TextDocument: TextDocumentItem{
|
||||||
URI: uri,
|
URI: uri,
|
||||||
@@ -399,10 +465,7 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
|
|||||||
return fmt.Errorf("didOpen failed: %w", err)
|
return fmt.Errorf("didOpen failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.mu.Lock()
|
|
||||||
srv.openDocs[uri] = 1
|
srv.openDocs[uri] = 1
|
||||||
srv.mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +500,7 @@ func (m *Manager) CloseDocument(_ context.Context, lang protocol.Language, file
|
|||||||
|
|
||||||
// reapIdleServers periodically closes idle servers.
|
// reapIdleServers periodically closes idle servers.
|
||||||
func (m *Manager) reapIdleServers() {
|
func (m *Manager) reapIdleServers() {
|
||||||
ticker := time.NewTicker(60 * time.Second)
|
ticker := time.NewTicker(ReaperInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -445,6 +508,10 @@ func (m *Manager) reapIdleServers() {
|
|||||||
case <-m.stopReaper:
|
case <-m.stopReaper:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
// Collect idle servers first to avoid holding the lock while closing
|
||||||
|
var toClose []*ManagedServer
|
||||||
|
var toCloseLanguages []protocol.Language
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
for lang, srv := range m.servers {
|
for lang, srv := range m.servers {
|
||||||
// Check lastUsed with server's lock to avoid race condition
|
// Check lastUsed with server's lock to avoid race condition
|
||||||
@@ -453,36 +520,45 @@ func (m *Manager) reapIdleServers() {
|
|||||||
srv.mu.Unlock()
|
srv.mu.Unlock()
|
||||||
|
|
||||||
if idle {
|
if idle {
|
||||||
m.logger.Info("closing idle LSP server", "language", lang)
|
toClose = append(toClose, srv)
|
||||||
_ = srv.client.Close()
|
toCloseLanguages = append(toCloseLanguages, lang)
|
||||||
delete(m.servers, lang)
|
delete(m.servers, lang)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
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 {
|
func (m *Manager) Close() error {
|
||||||
close(m.stopReaper)
|
m.closeOnce.Do(func() {
|
||||||
|
close(m.stopReaper)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
m.stopped = true
|
m.stopped = true
|
||||||
|
|
||||||
for lang, srv := range m.servers {
|
for lang, srv := range m.servers {
|
||||||
m.logger.Info("shutting down LSP server", "language", lang)
|
m.logger.Info("shutting down LSP server", "language", lang)
|
||||||
// Try graceful shutdown
|
// Try graceful shutdown
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
|
||||||
_, _ = srv.client.Call(ctx, "shutdown", nil)
|
_, _ = srv.client.Call(ctx, "shutdown", nil)
|
||||||
cancel()
|
cancel()
|
||||||
_ = srv.client.Notify("exit", nil)
|
_ = srv.client.Notify("exit", nil)
|
||||||
_ = srv.client.Close()
|
_ = srv.client.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
m.servers = make(map[protocol.Language]*ManagedServer)
|
m.servers = make(map[protocol.Language]*ManagedServer)
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,13 +573,13 @@ func (m *Manager) IsAvailable(lang protocol.Language) bool {
|
|||||||
return err == nil
|
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 {
|
func fileToURI(file string) string {
|
||||||
absPath, err := filepath.Abs(file)
|
absPath, err := filepath.Abs(file)
|
||||||
if err != nil {
|
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.
|
// URIToFile converts a file URI to a file path.
|
||||||
@@ -529,6 +605,8 @@ func languageToLSPID(lang protocol.Language) string {
|
|||||||
return "c"
|
return "c"
|
||||||
case protocol.LangCpp:
|
case protocol.LangCpp:
|
||||||
return "cpp"
|
return "cpp"
|
||||||
|
case protocol.LangRust:
|
||||||
|
return "rust"
|
||||||
default:
|
default:
|
||||||
return string(lang)
|
return string(lang)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package lsp
|
package lsp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cespare/xxhash/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestLRUCacheEviction tests that the LRU cache properly evicts old entries.
|
// TestLRUCacheEviction tests that the LRU cache properly evicts old entries.
|
||||||
@@ -82,8 +84,8 @@ func TestContentHashCollisionResistance(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
hash1 := contentHash(tc.content1)
|
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content1))
|
||||||
hash2 := contentHash(tc.content2)
|
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content2))
|
||||||
|
|
||||||
if hash1 == hash2 {
|
if hash1 == hash2 {
|
||||||
t.Errorf("Hash collision: %s == %s for different content", 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) {
|
func TestContentHashConsistency(t *testing.T) {
|
||||||
content := []byte("package main\n\nfunc test() {}\n")
|
content := []byte("package main\n\nfunc test() {}\n")
|
||||||
|
|
||||||
hash1 := contentHash(content)
|
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||||
hash2 := contentHash(content)
|
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||||
hash3 := contentHash(content)
|
hash3 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||||
|
|
||||||
if hash1 != hash2 || hash2 != hash3 {
|
if hash1 != hash2 || hash2 != hash3 {
|
||||||
t.Errorf("Hash inconsistency: %s, %s, %s", hash1, hash2, hash3)
|
t.Errorf("Hash inconsistency: %s, %s, %s", hash1, hash2, hash3)
|
||||||
@@ -115,7 +117,7 @@ func BenchmarkContentHash_xxHash(b *testing.B) {
|
|||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_ = contentHash(content)
|
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
|
|||||||
return extractCDocComment(n, content)
|
return extractCDocComment(n, content)
|
||||||
case protocol.LangElixir:
|
case protocol.LangElixir:
|
||||||
return extractElixirDocComment(n, content)
|
return extractElixirDocComment(n, content)
|
||||||
|
case protocol.LangRust:
|
||||||
|
return extractRustDocComment(n, content)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -551,6 +553,64 @@ func cleanPythonDocstring(doc string) string {
|
|||||||
return strings.TrimSpace(doc)
|
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.
|
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
|
||||||
// Elixir uses module attributes like @doc and @moduledoc for documentation.
|
// Elixir uses module attributes like @doc and @moduledoc for documentation.
|
||||||
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
|
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func NoComment() {}
|
|||||||
|
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
t.Fatal("expected doc, got nil")
|
t.Fatal("expected doc, got nil")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if doc.Text != tt.wantText {
|
if doc.Text != tt.wantText {
|
||||||
@@ -192,6 +193,7 @@ function validate(name) {}
|
|||||||
|
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
t.Fatal("expected doc, got nil")
|
t.Fatal("expected doc, got nil")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if doc.Text != tt.wantText {
|
if doc.Text != tt.wantText {
|
||||||
@@ -291,6 +293,7 @@ func TestExtractPythonDocComment(t *testing.T) {
|
|||||||
|
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
t.Fatal("expected doc, got nil")
|
t.Fatal("expected doc, got nil")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if doc.Text != tt.wantText {
|
if doc.Text != tt.wantText {
|
||||||
@@ -372,6 +375,7 @@ int simple() { return 1; }
|
|||||||
|
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
t.Fatal("expected doc, got nil")
|
t.Fatal("expected doc, got nil")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if doc.Text != tt.wantText {
|
if doc.Text != tt.wantText {
|
||||||
|
|||||||
+74
-24
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/cespare/xxhash/v2"
|
"github.com/cespare/xxhash/v2"
|
||||||
lru "github.com/hashicorp/golang-lru/v2"
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
@@ -16,20 +18,33 @@ import (
|
|||||||
"github.com/smacker/go-tree-sitter/html"
|
"github.com/smacker/go-tree-sitter/html"
|
||||||
"github.com/smacker/go-tree-sitter/javascript"
|
"github.com/smacker/go-tree-sitter/javascript"
|
||||||
"github.com/smacker/go-tree-sitter/python"
|
"github.com/smacker/go-tree-sitter/python"
|
||||||
|
"github.com/smacker/go-tree-sitter/rust"
|
||||||
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxFileSize is the maximum file size we'll parse (10MB).
|
// maxFileSize is the default maximum file size we'll parse (10MB).
|
||||||
const MaxFileSize = 10 * 1024 * 1024
|
const maxFileSize = 10 * 1024 * 1024
|
||||||
|
|
||||||
// Registry manages Tree-sitter parsers for different languages.
|
// Registry manages Tree-sitter parsers for different languages.
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
parsers map[protocol.Language]*sitter.Parser
|
parsers map[protocol.Language]*sitter.Parser
|
||||||
cache *lru.Cache[string, *CachedTree]
|
cache *lru.Cache[string, *CachedTree]
|
||||||
mu sync.RWMutex
|
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.
|
// CachedTree stores a parsed tree with its metadata.
|
||||||
@@ -54,8 +69,15 @@ type SyntaxError struct {
|
|||||||
Location protocol.Location
|
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 {
|
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
|
// Create LRU cache with capacity of 100 trees
|
||||||
cache, err := lru.New[string, *CachedTree](100)
|
cache, err := lru.New[string, *CachedTree](100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,9 +85,14 @@ func NewRegistry() *Registry {
|
|||||||
panic(fmt.Sprintf("failed to create LRU cache: %v", err))
|
panic(fmt.Sprintf("failed to create LRU cache: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxParseSize <= 0 {
|
||||||
|
maxParseSize = maxFileSize
|
||||||
|
}
|
||||||
|
|
||||||
return &Registry{
|
return &Registry{
|
||||||
parsers: make(map[protocol.Language]*sitter.Parser),
|
parsers: make(map[protocol.Language]*sitter.Parser),
|
||||||
cache: cache,
|
cache: cache,
|
||||||
|
maxParseSize: maxParseSize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,16 +118,23 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
|
|||||||
return html.GetLanguage(), nil
|
return html.GetLanguage(), nil
|
||||||
case protocol.LangElixir:
|
case protocol.LangElixir:
|
||||||
return elixir.GetLanguage(), nil
|
return elixir.GetLanguage(), nil
|
||||||
|
case protocol.LangRust:
|
||||||
|
return rust.GetLanguage(), nil
|
||||||
default:
|
default:
|
||||||
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
|
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
|
||||||
WithContext("language", string(lang)).
|
WithContext("language", string(lang)).
|
||||||
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir")
|
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, Rust")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetParser returns a parser for the given language.
|
// 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) {
|
func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
||||||
r.mu.RLock()
|
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 {
|
if p, ok := r.parsers[lang]; ok {
|
||||||
r.mu.RUnlock()
|
r.mu.RUnlock()
|
||||||
return p, nil
|
return p, nil
|
||||||
@@ -111,6 +145,11 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
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
|
// Double-check after acquiring write lock
|
||||||
if p, ok := r.parsers[lang]; ok {
|
if p, ok := r.parsers[lang]; ok {
|
||||||
return p, nil
|
return p, nil
|
||||||
@@ -130,9 +169,9 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
|||||||
|
|
||||||
// Parse parses the given content for the specified language.
|
// Parse parses the given content for the specified language.
|
||||||
func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||||
// Check file size
|
// Check file size against configured limit
|
||||||
if len(content) > MaxFileSize {
|
if int64(len(content)) > r.maxParseSize {
|
||||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
return nil, errors.NewFileTooLarge(filename, int64(len(content)), r.maxParseSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect binary files
|
// Detect binary files
|
||||||
@@ -159,8 +198,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check cache (LRU cache is thread-safe)
|
// 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 {
|
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
|
||||||
|
r.cacheHits.Add(1)
|
||||||
errors := extractErrors(cached.Tree.RootNode(), content)
|
errors := extractErrors(cached.Tree.RootNode(), content)
|
||||||
return &ParseResult{
|
return &ParseResult{
|
||||||
Tree: cached.Tree,
|
Tree: cached.Tree,
|
||||||
@@ -169,6 +210,7 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
|||||||
Content: content,
|
Content: content,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
r.cacheMisses.Add(1)
|
||||||
|
|
||||||
// Get parser
|
// Get parser
|
||||||
parser, err := r.GetParser(lang)
|
parser, err := r.GetParser(lang)
|
||||||
@@ -176,11 +218,22 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse content - tree-sitter parsers are not thread-safe,
|
// Parse content - tree-sitter parsers are not thread-safe per instance,
|
||||||
// so we need to hold the lock during parsing
|
// but parsers for different languages are independent.
|
||||||
r.mu.Lock()
|
// 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)
|
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 {
|
if err != nil {
|
||||||
return nil, errors.NewParseError(string(lang), filename, err)
|
return nil, errors.NewParseError(string(lang), filename, err)
|
||||||
@@ -239,13 +292,6 @@ func extractErrors(node *sitter.Node, _ []byte) []SyntaxError {
|
|||||||
return errors
|
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.
|
// isBinary checks if content appears to be binary.
|
||||||
func isBinary(content []byte) bool {
|
func isBinary(content []byte) bool {
|
||||||
// Check first 8000 bytes for null bytes
|
// Check first 8000 bytes for null bytes
|
||||||
@@ -260,10 +306,14 @@ func isBinary(content []byte) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close closes all parsers and clears the cache.
|
// Close closes all parsers and clears the cache.
|
||||||
|
// After Close is called, the registry cannot be used for parsing.
|
||||||
func (r *Registry) Close() {
|
func (r *Registry) Close() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Mark as closed first to prevent new parse operations
|
||||||
|
r.closed = true
|
||||||
|
|
||||||
for _, p := range r.parsers {
|
for _, p := range r.parsers {
|
||||||
p.Close()
|
p.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
|
|||||||
return extractCSymbols(root, content, filename)
|
return extractCSymbols(root, content, filename)
|
||||||
case protocol.LangElixir:
|
case protocol.LangElixir:
|
||||||
return extractElixirSymbols(root, content, filename)
|
return extractElixirSymbols(root, content, filename)
|
||||||
|
case protocol.LangRust:
|
||||||
|
return extractRustSymbols(root, content, filename)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -714,6 +716,133 @@ func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// extractElixirImpl extracts a protocol implementation.
|
||||||
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||||
// defimpl Protocol, for: Type do ... end
|
// defimpl Protocol, for: Type do ... end
|
||||||
@@ -749,3 +878,72 @@ func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protoco
|
|||||||
Location: NodeLocation(n, filename),
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ type JSONNode struct {
|
|||||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
|
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
|
||||||
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||||
// Check file size
|
// Check file size
|
||||||
if len(content) > MaxFileSize {
|
if len(content) > maxFileSize {
|
||||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML
|
// 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
|
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
|
||||||
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||||
// Check file size
|
// Check file size
|
||||||
if len(content) > MaxFileSize {
|
if len(content) > maxFileSize {
|
||||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON to validate syntax
|
// Parse JSON to validate syntax
|
||||||
|
|||||||
@@ -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
@@ -6,37 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
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.
|
// ASTQuery defines a query for matching AST patterns.
|
||||||
type ASTQuery struct {
|
type ASTQuery struct {
|
||||||
Pattern string `json:"pattern"` // code pattern with $VAR placeholders
|
Pattern string `json:"pattern"` // code pattern with $VAR placeholders
|
||||||
@@ -46,13 +22,11 @@ type ASTQuery struct {
|
|||||||
|
|
||||||
// QueryFilters provide additional filtering criteria.
|
// QueryFilters provide additional filtering criteria.
|
||||||
type QueryFilters struct {
|
type QueryFilters struct {
|
||||||
HasChild *ASTQuery `json:"has_child,omitempty"`
|
NameMatches string `json:"name_matches,omitempty"`
|
||||||
HasParent *ASTQuery `json:"has_parent,omitempty"`
|
NameExact string `json:"name_exact,omitempty"`
|
||||||
NameMatches string `json:"name_matches,omitempty"`
|
InFile string `json:"in_file,omitempty"`
|
||||||
NameExact string `json:"name_exact,omitempty"`
|
NotInFile string `json:"not_in_file,omitempty"`
|
||||||
InFile string `json:"in_file,omitempty"`
|
KindIn []string `json:"kind_in,omitempty"`
|
||||||
NotInFile string `json:"not_in_file,omitempty"`
|
|
||||||
KindIn []string `json:"kind_in,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResult represents a single match from a query.
|
// 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++)
|
// 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" {
|
if nodeType != "type_declaration" && nodeType != "struct_specifier" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -438,7 +412,7 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
name := parser.GetNodeText(nameNode, content)
|
name := parser.GetNodeText(nameNode, content)
|
||||||
re, err := compileRegex(filters.NameMatches)
|
re, err := util.CompileRegex(filters.NameMatches)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -477,62 +451,121 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
|
|||||||
return true
|
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 {
|
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 {
|
if len(results) == 0 {
|
||||||
return "No matches found."
|
return "No matches found."
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
// Apply offset (pagination skip).
|
||||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n\n", len(results)))
|
if offset > 0 {
|
||||||
|
if offset >= len(results) {
|
||||||
displayCount := len(results)
|
return "No matches found."
|
||||||
truncated := false
|
}
|
||||||
if maxResults > 0 && displayCount > maxResults {
|
results = results[offset:]
|
||||||
displayCount = maxResults
|
|
||||||
truncated = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < displayCount; i++ {
|
// Determine how many to render and whether more remain.
|
||||||
r := results[i]
|
renderCount := len(results)
|
||||||
nodeType := "unknown"
|
remaining := 0
|
||||||
if r.Node != nil {
|
if maxResults > 0 && renderCount > maxResults {
|
||||||
nodeType = r.Node.Type()
|
remaining = renderCount - maxResults
|
||||||
}
|
renderCount = maxResults
|
||||||
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
|
}
|
||||||
|
|
||||||
// Truncate very long text
|
var sb strings.Builder
|
||||||
text := r.Text
|
|
||||||
if len(text) > 500 {
|
|
||||||
text = text[:500] + "..."
|
|
||||||
}
|
|
||||||
sb.WriteString("```\n")
|
|
||||||
sb.WriteString(text)
|
|
||||||
sb.WriteString("\n```\n")
|
|
||||||
|
|
||||||
// Show captures
|
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
||||||
if len(r.Captures) > 0 {
|
wantVerbose := len(verbose) > 0 && verbose[0]
|
||||||
sb.WriteString("Captures: ")
|
if wantVerbose {
|
||||||
first := true
|
sb.WriteString(fmt.Sprintf("Found %d match(es):\n", renderCount))
|
||||||
for name, cap := range r.Captures {
|
}
|
||||||
if !first {
|
|
||||||
sb.WriteString(", ")
|
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
|
sb.WriteString("\n")
|
||||||
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 {
|
if remaining > 0 {
|
||||||
sb.WriteString(fmt.Sprintf("... and %d more matches (truncated)\n", len(results)-maxResults))
|
// 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()
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestQueryValidation(t *testing.T) {
|
||||||
reg := parser.NewRegistry()
|
reg := parser.NewRegistry()
|
||||||
defer reg.Close()
|
defer reg.Close()
|
||||||
|
|||||||
@@ -4,23 +4,25 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCompileRegexCaching tests that regex compilation is cached.
|
// TestCompileRegexCaching tests that regex compilation is cached.
|
||||||
func TestCompileRegexCaching(t *testing.T) {
|
func TestCompileRegexCaching(t *testing.T) {
|
||||||
// Clear cache before test
|
// Clear cache before test
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
pattern := `^test_\w+$`
|
pattern := `^test_\w+$`
|
||||||
|
|
||||||
// First compilation
|
// First compilation
|
||||||
re1, err := compileRegex(pattern)
|
re1, err := util.CompileRegex(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("First compile failed: %v", err)
|
t.Fatalf("First compile failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second compilation should return cached version
|
// Second compilation should return cached version
|
||||||
re2, err := compileRegex(pattern)
|
re2, err := util.CompileRegex(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Second compile failed: %v", err)
|
t.Fatalf("Second compile failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,22 +31,12 @@ func TestCompileRegexCaching(t *testing.T) {
|
|||||||
if re1 != re2 {
|
if re1 != re2 {
|
||||||
t.Error("Expected cached regex to be reused, got different objects")
|
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.
|
// TestCompileRegexConcurrent tests concurrent regex compilation.
|
||||||
func TestCompileRegexConcurrent(t *testing.T) {
|
func TestCompileRegexConcurrent(t *testing.T) {
|
||||||
// Clear cache before test
|
// Clear cache before test
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
pattern := `[a-z]+_\d+`
|
pattern := `[a-z]+_\d+`
|
||||||
const numGoroutines = 100
|
const numGoroutines = 100
|
||||||
@@ -60,7 +52,7 @@ func TestCompileRegexConcurrent(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
re, err := compileRegex(pattern)
|
re, err := util.CompileRegex(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors <- err
|
errors <- err
|
||||||
return
|
return
|
||||||
@@ -89,26 +81,30 @@ func TestCompileRegexConcurrent(t *testing.T) {
|
|||||||
// TestCompileRegexInvalidPattern tests error handling for invalid patterns.
|
// TestCompileRegexInvalidPattern tests error handling for invalid patterns.
|
||||||
func TestCompileRegexInvalidPattern(t *testing.T) {
|
func TestCompileRegexInvalidPattern(t *testing.T) {
|
||||||
// Clear cache before test
|
// Clear cache before test
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
invalidPattern := `[invalid(`
|
invalidPattern := `[invalid(`
|
||||||
|
|
||||||
_, err := compileRegex(invalidPattern)
|
_, err := util.CompileRegex(invalidPattern)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for invalid pattern, got nil")
|
t.Error("Expected error for invalid pattern, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid patterns should not be cached
|
// Verify that a valid pattern still works after an invalid one
|
||||||
_, ok := regexCache.Load(invalidPattern)
|
validPattern := `^valid$`
|
||||||
if ok {
|
re, err := util.CompileRegex(validPattern)
|
||||||
t.Error("Invalid pattern should not be cached")
|
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.
|
// TestCompileRegexMultiplePatterns tests that different patterns are cached separately.
|
||||||
func TestCompileRegexMultiplePatterns(t *testing.T) {
|
func TestCompileRegexMultiplePatterns(t *testing.T) {
|
||||||
// Clear cache before test
|
// Clear cache before test
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`^test_\w+$`,
|
`^test_\w+$`,
|
||||||
@@ -121,26 +117,14 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
|
|||||||
|
|
||||||
// Compile all patterns
|
// Compile all patterns
|
||||||
for i, pattern := range patterns {
|
for i, pattern := range patterns {
|
||||||
re, err := compileRegex(pattern)
|
re, err := util.CompileRegex(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Compile failed for pattern %s: %v", pattern, err)
|
t.Fatalf("Compile failed for pattern %s: %v", pattern, err)
|
||||||
}
|
}
|
||||||
compiled[i] = re
|
compiled[i] = re
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all are cached
|
// All should be different objects (different patterns)
|
||||||
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
|
|
||||||
for i := 0; i < len(compiled); i++ {
|
for i := 0; i < len(compiled); i++ {
|
||||||
for j := i + 1; j < len(compiled); j++ {
|
for j := i + 1; j < len(compiled); j++ {
|
||||||
if compiled[i] == 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.
|
// BenchmarkCompileRegex_Uncached benchmarks regex compilation without caching.
|
||||||
@@ -163,23 +158,23 @@ func BenchmarkCompileRegex_Uncached(b *testing.B) {
|
|||||||
// BenchmarkCompileRegex_Cached benchmarks regex compilation with caching.
|
// BenchmarkCompileRegex_Cached benchmarks regex compilation with caching.
|
||||||
func BenchmarkCompileRegex_Cached(b *testing.B) {
|
func BenchmarkCompileRegex_Cached(b *testing.B) {
|
||||||
// Clear cache
|
// Clear cache
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
pattern := `^\w+_[0-9]{3,5}_[a-zA-Z]+$`
|
pattern := `^\w+_[0-9]{3,5}_[a-zA-Z]+$`
|
||||||
|
|
||||||
// Pre-populate cache
|
// Pre-populate cache
|
||||||
_, _ = compileRegex(pattern)
|
_, _ = util.CompileRegex(pattern)
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, _ = compileRegex(pattern)
|
_, _ = util.CompileRegex(pattern)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BenchmarkCompileRegex_MixedPatterns benchmarks realistic workload with multiple patterns.
|
// BenchmarkCompileRegex_MixedPatterns benchmarks realistic workload with multiple patterns.
|
||||||
func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
|
func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
|
||||||
// Clear cache
|
// Clear cache
|
||||||
regexCache = sync.Map{}
|
util.ClearRegexCache()
|
||||||
|
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`^test_\w+$`,
|
`^test_\w+$`,
|
||||||
@@ -193,6 +188,6 @@ func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
|
|||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
// Simulate realistic access pattern
|
// Simulate realistic access pattern
|
||||||
pattern := patterns[i%len(patterns)]
|
pattern := patterns[i%len(patterns)]
|
||||||
_, _ = compileRegex(pattern)
|
_, _ = util.CompileRegex(pattern)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -131,6 +131,11 @@ func (s *Searcher) Search(ctx context.Context, req *Request) (*SearchResults, er
|
|||||||
WithRemediation("Provide a non-empty search pattern")
|
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
|
// Build ripgrep command
|
||||||
args := s.buildArgs(req)
|
args := s.buildArgs(req)
|
||||||
|
|
||||||
@@ -214,10 +219,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
|
|||||||
args = append(args, "--no-ignore")
|
args = append(args, "--no-ignore")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max count per file to limit results
|
// Result cap enforced in-process by parseOutput. rg has no cross-file
|
||||||
if req.MaxResults > 0 {
|
// total-count flag in stable releases, so we don't pass one; --max-count is
|
||||||
args = append(args, fmt.Sprintf("--max-count=%d", req.MaxResults))
|
// per-file and would miss results unevenly.
|
||||||
}
|
|
||||||
|
|
||||||
// Add pattern
|
// Add pattern
|
||||||
args = append(args, "--", req.Pattern)
|
args = append(args, "--", req.Pattern)
|
||||||
@@ -237,15 +241,31 @@ func (s *Searcher) buildArgs(req *Request) []string {
|
|||||||
return args
|
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.
|
// parseOutput parses ripgrep JSON output.
|
||||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||||
results := &SearchResults{
|
results := &SearchResults{
|
||||||
Results: []Result{},
|
Results: []Result{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track context by file and line
|
// Track before-context lines linearly: accumulate context lines until the next match consumes them.
|
||||||
contextBefore := make(map[string][]string) // file -> lines before current match
|
var pendingBefore []string
|
||||||
currentFile := ""
|
pendingFile := ""
|
||||||
|
|
||||||
scanner := bufio.NewScanner(output)
|
scanner := bufio.NewScanner(output)
|
||||||
for scanner.Scan() {
|
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
|
result.Column = match.Submatches[0].Start + 1 // 1-indexed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add context before
|
// Attach pending before-context if it belongs to this file
|
||||||
if ctx, ok := contextBefore[match.Path.Text]; ok {
|
if pendingFile == match.Path.Text && len(pendingBefore) > 0 {
|
||||||
result.Context.Before = ctx
|
result.Context.Before = pendingBefore
|
||||||
delete(contextBefore, match.Path.Text)
|
|
||||||
}
|
}
|
||||||
|
pendingBefore = nil
|
||||||
|
pendingFile = ""
|
||||||
|
|
||||||
results.Results = append(results.Results, result)
|
results.Results = append(results.Results, result)
|
||||||
currentFile = match.Path.Text
|
|
||||||
|
|
||||||
case "context":
|
case "context":
|
||||||
var ctx rgContext
|
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")
|
lineText := strings.TrimRight(ctx.Lines.Text, "\n\r")
|
||||||
|
|
||||||
// Determine if this is before or after context
|
isAfter := false
|
||||||
if len(results.Results) > 0 {
|
if len(results.Results) > 0 {
|
||||||
lastResult := &results.Results[len(results.Results)-1]
|
last := &results.Results[len(results.Results)-1]
|
||||||
if lastResult.File == ctx.Path.Text && ctx.LineNumber > lastResult.Line {
|
if last.File == ctx.Path.Text && ctx.LineNumber > last.Line {
|
||||||
// This is after context
|
last.Context.After = append(last.Context.After, lineText)
|
||||||
lastResult.Context.After = append(lastResult.Context.After, lineText)
|
isAfter = true
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Before any match - store as potential before context
|
if !isAfter {
|
||||||
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
|
if pendingFile != ctx.Path.Text {
|
||||||
|
pendingBefore = nil
|
||||||
|
pendingFile = ctx.Path.Text
|
||||||
|
}
|
||||||
|
pendingBefore = append(pendingBefore, lineText)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "summary":
|
case "summary":
|
||||||
@@ -333,8 +354,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
|||||||
return results, nil
|
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 {
|
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 {
|
if len(results.Results) == 0 {
|
||||||
return "No matches found."
|
return "No matches found."
|
||||||
}
|
}
|
||||||
@@ -351,14 +385,18 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
|||||||
fileResults[r.File] = append(fileResults[r.File], r)
|
fileResults[r.File] = append(fileResults[r.File], r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write summary
|
// Write preamble only when Verbose is requested.
|
||||||
totalMatches := len(results.Results)
|
if opts.Verbose {
|
||||||
fileCount := len(fileResults)
|
totalMatches := len(results.Results)
|
||||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
fileCount := len(fileResults)
|
||||||
if results.Truncated {
|
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
||||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
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
|
// Write results grouped by file
|
||||||
for _, file := range fileOrder {
|
for _, file := range fileOrder {
|
||||||
@@ -372,26 +410,82 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
|||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
||||||
|
|
||||||
for _, r := range fileResults[file] {
|
if opts.Cluster {
|
||||||
// Write context before
|
writeClusteredResults(&sb, fileResults[file])
|
||||||
for _, ctx := range r.Context.Before {
|
} else {
|
||||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
writeVerboseResults(&sb, fileResults[file])
|
||||||
}
|
|
||||||
|
|
||||||
// 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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.CursorLine != "" {
|
||||||
|
sb.WriteString(opts.CursorLine)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
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.
|
// truncateLine truncates a line if it exceeds maxLen.
|
||||||
func truncateLine(s string, maxLen int) string {
|
func truncateLine(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req *Request
|
req *Request
|
||||||
expected []string
|
expected []string
|
||||||
|
notExpected []string // T-06: verify absence of unexpected flags
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic search",
|
name: "basic search",
|
||||||
@@ -54,7 +55,8 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
ContextLines: 2,
|
ContextLines: 2,
|
||||||
Regex: true,
|
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",
|
name: "ignore case",
|
||||||
@@ -63,7 +65,8 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
IgnoreCase: true,
|
IgnoreCase: true,
|
||||||
Regex: true,
|
Regex: true,
|
||||||
},
|
},
|
||||||
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
||||||
|
notExpected: []string{"--fixed-strings"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fixed strings",
|
name: "fixed strings",
|
||||||
@@ -71,7 +74,8 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
Pattern: "test",
|
Pattern: "test",
|
||||||
Regex: false,
|
Regex: false,
|
||||||
},
|
},
|
||||||
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
||||||
|
notExpected: []string{"--ignore-case"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with file types",
|
name: "with file types",
|
||||||
@@ -80,7 +84,8 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
FileTypes: []string{"go", "ts"},
|
FileTypes: []string{"go", "ts"},
|
||||||
Regex: true,
|
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",
|
name: "with max results",
|
||||||
@@ -89,7 +94,8 @@ func TestBuildArgs(t *testing.T) {
|
|||||||
MaxResults: 10,
|
MaxResults: 10,
|
||||||
Regex: true,
|
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.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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create temp dir: %v", err)
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
|
||||||
// Create test files
|
// Create test files
|
||||||
testFile := filepath.Join(tmpDir, "test.go")
|
testFile := filepath.Join(tmpDir, "test.go")
|
||||||
@@ -269,7 +285,7 @@ func main() {
|
|||||||
println("Hello, World!")
|
println("Hello, World!")
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
err = os.WriteFile(testFile, []byte(content), 0600)
|
err = os.WriteFile(testFile, []byte(content), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to write test file: %v", err)
|
t.Fatalf("failed to write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
-753
File diff suppressed because it is too large
Load Diff
+421
-92
@@ -5,7 +5,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
@@ -29,8 +31,8 @@ func TestNew(t *testing.T) {
|
|||||||
|
|
||||||
if srv == nil {
|
if srv == nil {
|
||||||
t.Fatal("New() returned nil server")
|
t.Fatal("New() returned nil server")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if srv.cfg != cfg {
|
if srv.cfg != cfg {
|
||||||
t.Error("server config mismatch")
|
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) {
|
func TestHandleFileRead(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
@@ -123,6 +87,7 @@ func Hello() {
|
|||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("handleFileRead() returned nil result")
|
t.Fatal("handleFileRead() returned nil result")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
contents := result.Content
|
contents := result.Content
|
||||||
@@ -179,6 +144,7 @@ func Hello() {
|
|||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("handleFileRead() returned nil result")
|
t.Fatal("handleFileRead() returned nil result")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
contents := result.Content
|
contents := result.Content
|
||||||
@@ -270,6 +236,7 @@ func Goodbye() error {
|
|||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("handleASTQuery() returned nil result")
|
t.Fatal("handleASTQuery() returned nil result")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
contents := result.Content
|
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) {
|
func TestHandleEditApply(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
@@ -360,18 +278,429 @@ func Hello() {
|
|||||||
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := srv.handleEdit(ctx, req, true)
|
result, err := srv.handleEditApply(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("handleEdit(apply) error = %v", err)
|
t.Errorf("handleEditApply error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("handleEdit(apply) returned nil result")
|
t.Fatal("handleEditApply returned nil result")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file WAS modified
|
// Verify file WAS modified
|
||||||
fileContent, _ := os.ReadFile(testFile)
|
fileContent, _ := os.ReadFile(testFile)
|
||||||
if string(fileContent) == content {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ®exErr) {
|
||||||
|
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, ®exErr) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,42 @@ func (e *StructuredError) WithRemediation(msg string) *StructuredError {
|
|||||||
return e
|
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.
|
// New creates a new structured error with stack trace.
|
||||||
func New(code ErrorCode, message string) *StructuredError {
|
func New(code ErrorCode, message string) *StructuredError {
|
||||||
return &StructuredError{
|
return &StructuredError{
|
||||||
|
|||||||
+8
-13
@@ -1,6 +1,8 @@
|
|||||||
// Package protocol defines shared types used across the MCP file operations server.
|
// Package protocol defines shared types used across the MCP file operations server.
|
||||||
package protocol
|
package protocol
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
// Location represents a position in a file.
|
// Location represents a position in a file.
|
||||||
type Location struct {
|
type Location struct {
|
||||||
File string `json:"file"`
|
File string `json:"file"`
|
||||||
@@ -30,6 +32,8 @@ const (
|
|||||||
SymbolProperty SymbolKind = "property"
|
SymbolProperty SymbolKind = "property"
|
||||||
SymbolModule SymbolKind = "module"
|
SymbolModule SymbolKind = "module"
|
||||||
SymbolPackage SymbolKind = "package"
|
SymbolPackage SymbolKind = "package"
|
||||||
|
SymbolEnum SymbolKind = "enum"
|
||||||
|
SymbolTrait SymbolKind = "trait"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Symbol represents a code symbol (function, class, variable, etc.).
|
// Symbol represents a code symbol (function, class, variable, etc.).
|
||||||
@@ -61,12 +65,13 @@ const (
|
|||||||
LangJSON Language = "json"
|
LangJSON Language = "json"
|
||||||
LangYAML Language = "yaml"
|
LangYAML Language = "yaml"
|
||||||
LangElixir Language = "elixir"
|
LangElixir Language = "elixir"
|
||||||
|
LangRust Language = "rust"
|
||||||
LangUnknown Language = "unknown"
|
LangUnknown Language = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetectLanguage detects the language from a filename.
|
// DetectLanguage detects the language from a filename.
|
||||||
func DetectLanguage(filename string) Language {
|
func DetectLanguage(filename string) Language {
|
||||||
ext := getExtension(filename)
|
ext := filepath.Ext(filename)
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".go":
|
case ".go":
|
||||||
return LangGo
|
return LangGo
|
||||||
@@ -90,19 +95,9 @@ func DetectLanguage(filename string) Language {
|
|||||||
return LangYAML
|
return LangYAML
|
||||||
case ".ex", ".exs":
|
case ".ex", ".exs":
|
||||||
return LangElixir
|
return LangElixir
|
||||||
|
case ".rs":
|
||||||
|
return LangRust
|
||||||
default:
|
default:
|
||||||
return LangUnknown
|
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 ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+65
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user