mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9b0f967f | ||
|
|
cefd6ba7b1 | ||
|
|
d82479c02e | ||
|
|
f6ba02e892 | ||
|
|
2344b38d59 | ||
|
|
2158228ddb | ||
|
|
f4f5135ac2 | ||
|
|
964e987776 | ||
|
|
a7fde76871 | ||
|
|
ed8611d07b | ||
|
|
700c6e818c | ||
|
|
25ee197acd | ||
|
|
a7dcab5146 | ||
|
|
d9d8332f77 | ||
|
|
a4bfdefdab | ||
|
|
32b0a7f69a | ||
|
|
76316de768 | ||
|
|
9abab7a550 | ||
|
|
658c895318 | ||
|
|
d24c8218fd | ||
|
|
f4c34c41e4 | ||
|
|
749076518d | ||
|
|
664b7dac45 | ||
|
|
d0c07f0157 | ||
|
|
a841210092 | ||
|
|
98a499ab20 | ||
|
|
8d940022b7 | ||
|
|
99de8a93b9 | ||
|
|
a11623cb0d | ||
|
|
5f0bb90e28 | ||
|
|
62971c9f8c | ||
|
|
99514ff8d6 | ||
|
|
2fb9d3e379 | ||
|
|
3195e353e2 | ||
|
|
549ec2a177 | ||
|
|
96db1c7749 | ||
|
|
5452cfce30 | ||
|
|
85a539b18b | ||
|
|
084c1fd2b2 | ||
|
|
2e444401f5 | ||
|
|
1b41e43bf3 | ||
|
|
13b1a22102 | ||
|
|
d0f649a9b8 | ||
|
|
5214ad2b66 | ||
|
|
80c3f03f62 | ||
|
|
61b04b46cd | ||
|
|
debe79e33e | ||
|
|
dfd68f08c9 | ||
|
|
b2c4101b5b | ||
|
|
53bc06df7f | ||
|
|
cc1661d295 | ||
|
|
b6489ee4bc | ||
|
|
5ed14457f1 | ||
|
|
1c26f8d105 | ||
|
|
7b8914017c | ||
|
|
5827290ece | ||
|
|
b0c1fc0480 | ||
|
|
a0b41a6eb8 | ||
|
|
c261fb8c76 | ||
|
|
ff37be1f17 | ||
|
|
36deb948c8 | ||
|
|
833e898d20 | ||
|
|
02d54b56bb | ||
|
|
e11f140997 | ||
|
|
aa7d884b0c | ||
|
|
0fe678cd60 | ||
|
|
70484d20c3 | ||
|
|
6e0c5247c1 | ||
|
|
7774dc29d1 | ||
|
|
9af2801b1b | ||
|
|
f1643e7b81 | ||
|
|
1fe8db9012 | ||
|
|
5ad975ee7a | ||
|
|
b131c1edd3 | ||
|
|
7829ae4891 | ||
|
|
0d91e94416 | ||
|
|
7ad20c33f9 | ||
|
|
90244fda67 | ||
|
|
9a9d81f2a4 | ||
|
|
78c5fc7236 | ||
|
|
ccfbdc513f | ||
|
|
db803c6950 | ||
|
|
34d4401280 | ||
|
|
267378181c | ||
|
|
2b83fccb35 | ||
|
|
996be39314 | ||
|
|
50da24756f | ||
|
|
a778e0b28b | ||
|
|
e2dc4a1f40 | ||
|
|
982c2c8b44 | ||
|
|
6980d3b294 | ||
|
|
9205b2bc26 | ||
|
|
143a166249 | ||
|
|
9d84c1253b | ||
|
|
b8d868115c |
@@ -1,4 +1,4 @@
|
||||
name: Autoupdate go.mod and go.sum
|
||||
name: Update dependencies
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,16 +6,16 @@ on:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
update:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
release-workflow: "release.yaml"
|
||||
node: false
|
||||
release-workflow: release.yaml
|
||||
lfs: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -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.26'
|
||||
cache: true
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.9.0
|
||||
args: --timeout=5m
|
||||
@@ -18,6 +18,14 @@ jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.26"
|
||||
rolling-release-tag: "v1"
|
||||
platforms: >-
|
||||
[
|
||||
{"os":"macos-15-intel","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
|
||||
|
||||
@@ -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.26'
|
||||
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
|
||||
bin/mcp-filepuff
|
||||
mcp-filepuff
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
go: '1.26'
|
||||
|
||||
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
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all"]
|
||||
exclusions:
|
||||
rules:
|
||||
# Exclude some linters from running on tests
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt # Check formatting
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
output:
|
||||
sort-order:
|
||||
- file
|
||||
+50
-5
@@ -5,13 +5,22 @@ version: 2
|
||||
|
||||
project_name: mcp-filepuff
|
||||
|
||||
# Split CGO builds by full target (GOOS+GOARCH), not just GOOS.
|
||||
# Each release matrix runner builds natively for exactly one target via the
|
||||
# GGOOS/GGOARCH filters set in workflow-prepare.sh. Without `by: target` the
|
||||
# split partitions by GOOS only, so a single linux runner would try to build
|
||||
# both amd64 and arm64 — and native gcc cannot assemble the other arch's CGO
|
||||
# runtime (gcc_arm64.S errors), breaking the release.
|
||||
partial:
|
||||
by: target
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- id: mcp-filepuff
|
||||
- id: mcp-filepuff-cgo
|
||||
main: ./cmd/mcp-filepuff
|
||||
binary: mcp-filepuff
|
||||
env:
|
||||
@@ -31,10 +40,6 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
@@ -90,6 +95,37 @@ release:
|
||||
```
|
||||
|
||||
|
||||
homebrew_casks:
|
||||
- name: mcp-filepuff
|
||||
repository:
|
||||
owner: lukaszraczylo
|
||||
name: homebrew-taps
|
||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||
directory: Casks
|
||||
homepage: https://github.com/lukaszraczylo/filepuff-mcp
|
||||
description: "AST-aware file operations and LSP integration MCP server for Claude Code"
|
||||
license: MIT
|
||||
hooks:
|
||||
post:
|
||||
install: |
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-dr", "com.apple.quarantine", "#{staged_path}/mcp-filepuff"]
|
||||
end
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
|
||||
- "ghcr.io/lukaszraczylo/filepuff-mcp:latest"
|
||||
ids:
|
||||
- mcp-filepuff-cgo
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
goarch: amd64
|
||||
goos: linux
|
||||
dockerfile: Dockerfile
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
@@ -101,4 +137,13 @@ signs:
|
||||
artifacts: checksum
|
||||
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=mcp-filepuff
|
||||
# Build directory
|
||||
@@ -42,12 +41,29 @@ build-all:
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
||||
$(GOTEST) -v -coverprofile=coverage.out ./...
|
||||
|
||||
# Run tests with race detector on critical packages
|
||||
test-race:
|
||||
@echo "Running race detector tests on critical packages..."
|
||||
$(GOTEST) -v -race -timeout=5m ./internal/edit/...
|
||||
$(GOTEST) -v -race -timeout=5m ./internal/lsp/...
|
||||
$(GOTEST) -v -race -timeout=5m ./internal/parser/...
|
||||
$(GOTEST) -v -race -timeout=5m ./internal/server/...
|
||||
@echo "Race detector tests completed successfully"
|
||||
|
||||
# Run tests with short flag
|
||||
test-short:
|
||||
$(GOTEST) -v -short ./...
|
||||
|
||||
# Run all tests including race detector
|
||||
test-all: test test-race
|
||||
|
||||
# Run linters
|
||||
lint:
|
||||
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
|
||||
golangci-lint run ./...
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
@@ -72,9 +88,18 @@ help:
|
||||
@echo " build - Build the binary"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " test - Run tests with coverage"
|
||||
@echo " test-race - Run tests with race detector on critical packages"
|
||||
@echo " test-all - Run all tests including race detector"
|
||||
@echo " test-short - Run short tests"
|
||||
@echo " lint - Run linters"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " install - Install binary to GOPATH/bin"
|
||||
@echo " run - Build and run the server"
|
||||
@echo " run-workspace - Run with specific workspace (WORKSPACE=/path)"
|
||||
|
||||
|
||||
# Generate API documentation
|
||||
docs:
|
||||
@echo "Generating API documentation..."
|
||||
$(GOCMD) run ./cmd/docgen
|
||||
@echo "Documentation generated in docs/API.md"
|
||||
@@ -8,7 +8,7 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
|
||||
- **AST-Aware File Reading**: Read files with symbol extraction using Tree-sitter
|
||||
- **Code Pattern Matching**: Query code using patterns with capture placeholders
|
||||
- **LSP Integration**: Go-to-definition, find references, and symbol info via language servers
|
||||
- **Safe Editing**: AST-aware file editing with syntax validation and preview
|
||||
- **Safe Editing**: AST-aware file editing with syntax validation (edit_apply)
|
||||
- **Multi-Language Support**: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, React
|
||||
- **Token Efficient**: Optimized for minimal token usage with symbols-only mode and output limiting
|
||||
|
||||
@@ -29,6 +29,30 @@ This script will:
|
||||
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
|
||||
- Make the binary executable
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/lukaszraczylo/filepuff-mcp:latest
|
||||
```
|
||||
|
||||
The MCP server communicates over stdio. Mount your workspace and run with `-i`:
|
||||
|
||||
```bash
|
||||
docker run -i --rm -v /path/to/workspace:/workspace ghcr.io/lukaszraczylo/filepuff-mcp:latest -workspace /workspace
|
||||
```
|
||||
|
||||
Claude Code configuration (`.claude/settings.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filepuff": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-v", ".:/workspace", "ghcr.io/lukaszraczylo/filepuff-mcp:latest", "-workspace", "/workspace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
|
||||
@@ -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.
|
||||
|
||||
### Recommended Claude Code Configuration
|
||||
|
||||
#### Selective Tool Deferral
|
||||
|
||||
For optimal performance, keep the most frequently used tools loaded at startup and defer the rest. This follows [Anthropic's recommended practice](https://www.anthropic.com/engineering/advanced-tool-use) of loading 3-5 high-use tools immediately:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filepuff": {
|
||||
"command": "mcp-filepuff",
|
||||
"args": ["-workspace", "."],
|
||||
"alwaysAllow": ["file_read", "file_search", "edit_apply"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This keeps `file_read`, `file_search`, and `edit_apply` immediately available while deferring less frequently used tools (`ping`, `ast_query`, `symbol_at`, `find_definition`, `find_references`).
|
||||
|
||||
#### System Prompt Guidance
|
||||
|
||||
Add the following to your `CLAUDE.md` to help Claude understand the available tool categories:
|
||||
|
||||
```markdown
|
||||
You have access to filepuff MCP tools providing:
|
||||
- File reading with AST symbol summaries (file_read)
|
||||
- Fast regex search powered by ripgrep (file_search)
|
||||
- Structural code pattern matching across 9+ languages (ast_query)
|
||||
- LSP-powered go-to-definition, find-references, and symbol info (find_definition, find_references, symbol_at)
|
||||
- AST-aware file editing with syntax validation (edit_apply)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Running the Server (Standalone)
|
||||
@@ -184,7 +241,7 @@ When performing file operations, prefer filepuff MCP tools over built-in equival
|
||||
| Read files | `mcp__filepuff__file_read` | Read |
|
||||
| Search content | `mcp__filepuff__file_search` | Grep |
|
||||
| AST pattern search | `mcp__filepuff__ast_query` | Grep/Glob |
|
||||
| Edit files | `mcp__filepuff__edit_preview` + `mcp__filepuff__edit_apply` | Edit |
|
||||
| Edit files | `mcp__filepuff__edit_apply` | Edit |
|
||||
| Find definitions | `mcp__filepuff__find_definition` | Grep |
|
||||
| Find references | `mcp__filepuff__find_references` | Grep |
|
||||
| Symbol info | `mcp__filepuff__symbol_at` | - |
|
||||
@@ -226,7 +283,7 @@ Search for text patterns in files using ripgrep.
|
||||
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
|
||||
- `ignore_case`: Case insensitive search
|
||||
- `regex`: Treat pattern as regex (default: true)
|
||||
- `context_lines`: Number of context lines around matches (default: 2)
|
||||
- `context_lines`: Number of context lines around matches (default: 0)
|
||||
- `max_results`: Maximum number of results to return
|
||||
|
||||
---
|
||||
@@ -295,7 +352,7 @@ Search for AST patterns in code files using structural pattern matching.
|
||||
- `name_matches`: Regex pattern to filter by name
|
||||
- `name_exact`: Exact name to match
|
||||
- `kind_in`: Node types to match (e.g., function_declaration)
|
||||
- `max_results`: Maximum number of results (default: 100)
|
||||
- `max_results`: Maximum number of results (default: 50)
|
||||
|
||||
**Examples**:
|
||||
```json
|
||||
@@ -342,8 +399,8 @@ Find all references to the symbol at a specific position.
|
||||
|
||||
---
|
||||
|
||||
### `edit_preview`
|
||||
Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).
|
||||
### `edit_apply`
|
||||
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
|
||||
|
||||
**Parameters**:
|
||||
- `file` (required): Path to the file to edit
|
||||
@@ -363,13 +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_pattern`: Regex pattern to match
|
||||
|
||||
---
|
||||
|
||||
### `edit_apply`
|
||||
Apply an edit to a file. Uses AST-aware editing for code files with syntax validation, and text-based editing for other files.
|
||||
|
||||
**Parameters**: Same as `edit_preview`
|
||||
|
||||
**Example (AST mode - Go file)**:
|
||||
```json
|
||||
{
|
||||
@@ -425,6 +475,7 @@ Apply an edit to a file. Uses AST-aware editing for code files with syntax valid
|
||||
| HTML | .html, .htm | Yes | Yes | - | Yes |
|
||||
| Vue | .vue | Yes | Yes* | - | Yes |
|
||||
| React | .jsx, .tsx | Yes | Yes | typescript-language-server | Yes |
|
||||
| Elixir | .ex, .exs | Yes | Yes | elixir-ls | Yes |
|
||||
|
||||
\* Vue uses HTML parser for template sections
|
||||
|
||||
@@ -479,13 +530,14 @@ make clean
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ MCP Server │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Tools: file_search, file_read, ast_query, symbol_at, │
|
||||
│ find_definition, find_references, │
|
||||
│ edit_preview, edit_apply, ping │
|
||||
│ find_definition, find_references, edit_apply, ping │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Core Engines │
|
||||
├───────────┬─────────────┬────────────┬─────────────────┤
|
||||
@@ -494,6 +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
|
||||
|
||||
### Common Issues
|
||||
@@ -545,7 +811,6 @@ The edit engine validates syntax before and after edits.
|
||||
|
||||
**Solution**:
|
||||
- Ensure `new_content` is syntactically valid for the target language
|
||||
- Use `edit_preview` first to see the proposed changes
|
||||
- Check that the selector matches exactly one node
|
||||
|
||||
#### Timeout Errors
|
||||
|
||||
@@ -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")
|
||||
fmt.Fprintf(&sb, "> 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 {
|
||||
fmt.Fprintf(&sb, "### %s\n", cat)
|
||||
for _, tool := range categories[cat] {
|
||||
fmt.Fprintf(&sb, "- [`%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 {
|
||||
fmt.Fprintf(&sb, "### %s\n\n", cat)
|
||||
|
||||
for _, tool := range categories[cat] {
|
||||
fmt.Fprintf(&sb, "#### `%s`\n\n", tool.Name)
|
||||
fmt.Fprintf(&sb, "%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**"
|
||||
}
|
||||
fmt.Fprintf(&sb, "| `%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 {
|
||||
fmt.Fprintf(&sb, "- %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 logFileErr error
|
||||
if logFile != "" {
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
logFileErr = err
|
||||
// Fallback to stderr
|
||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||
} else {
|
||||
@@ -80,5 +82,15 @@ func setupLogger(level string, logFile string) *slog.Logger {
|
||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
return slog.New(handler)
|
||||
logger := slog.New(handler)
|
||||
|
||||
// Warn if log file couldn't be opened
|
||||
if logFileErr != nil {
|
||||
logger.Warn("failed to open log file, using stderr",
|
||||
"file", logFile,
|
||||
"error", logFileErr,
|
||||
)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
+1688
-1342
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: 0) |
|
||||
| `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: 50) |
|
||||
|
||||
**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
|
||||
@@ -1,24 +1,22 @@
|
||||
module github.com/lukaszraczylo/mcp-filepuff
|
||||
|
||||
go 1.25.5
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/goccy/go-json v0.11.2
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/mark3labs/mcp-go v1.1.1
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -26,14 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mark3labs/mcp-go v1.1.1 h1:PMZjyayCF01Y4R2kQXgDtsmxVLOdq1Mol4CnzzTYSEo=
|
||||
github.com/mark3labs/mcp-go v1.1.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||
@@ -42,15 +40,18 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+96
-28
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,40 +13,46 @@ import (
|
||||
|
||||
// Config holds all configuration options for the MCP server.
|
||||
type Config struct {
|
||||
Formatters map[string]string `json:"formatters"`
|
||||
WorkspaceRoot string `json:"workspace_root"`
|
||||
LSPTimeout time.Duration `json:"lsp_timeout"`
|
||||
SearchTimeout time.Duration `json:"search_timeout"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
MaxSearchResults int `json:"max_search_results"`
|
||||
MaxEditSize int64 `json:"max_edit_size"`
|
||||
EnableLSP bool `json:"enable_lsp"`
|
||||
FollowSymlinks bool `json:"follow_symlinks"`
|
||||
RespectGitignore bool `json:"respect_gitignore"`
|
||||
Formatters map[string]string `json:"formatters"`
|
||||
WorkspaceRoot string `json:"workspace_root"`
|
||||
LSPTimeout time.Duration `json:"lsp_timeout"`
|
||||
SearchTimeout time.Duration `json:"search_timeout"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
MaxParseSize int64 `json:"max_parse_size"`
|
||||
MaxSearchResults int `json:"max_search_results"`
|
||||
MaxEditSize int64 `json:"max_edit_size"`
|
||||
EnableLSP bool `json:"enable_lsp"`
|
||||
FollowSymlinks bool `json:"follow_symlinks"`
|
||||
RespectGitignore bool `json:"respect_gitignore"`
|
||||
ResourceLinkThresholdBytes int `json:"resource_link_threshold_bytes"`
|
||||
}
|
||||
|
||||
// Default values for configuration.
|
||||
const (
|
||||
DefaultLSPTimeout = 5 * time.Minute
|
||||
DefaultSearchTimeout = 30 * time.Second
|
||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxSearchResults = 1000
|
||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||
DefaultLSPTimeout = 5 * time.Minute
|
||||
DefaultSearchTimeout = 30 * time.Second
|
||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxSearchResults = 50
|
||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
||||
)
|
||||
|
||||
// Default returns a Config with default values.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
WorkspaceRoot: ".",
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
SearchTimeout: DefaultSearchTimeout,
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxSearchResults: DefaultMaxSearchResults,
|
||||
MaxEditSize: DefaultMaxEditSize,
|
||||
EnableLSP: true,
|
||||
Formatters: make(map[string]string),
|
||||
FollowSymlinks: true,
|
||||
RespectGitignore: true,
|
||||
WorkspaceRoot: ".",
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
SearchTimeout: DefaultSearchTimeout,
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
MaxSearchResults: DefaultMaxSearchResults,
|
||||
MaxEditSize: DefaultMaxEditSize,
|
||||
EnableLSP: true,
|
||||
Formatters: make(map[string]string),
|
||||
FollowSymlinks: true,
|
||||
RespectGitignore: true,
|
||||
ResourceLinkThresholdBytes: DefaultResourceLinkThresholdBytes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +72,36 @@ func Load(workspaceRoot string) (*Config, error) {
|
||||
cfg.WorkspaceRoot = cwd
|
||||
}
|
||||
|
||||
// Try to load from config file in workspace root
|
||||
// Try to load from config file in workspace root.
|
||||
// Save WorkspaceRoot before loading config file so it cannot be overridden.
|
||||
savedRoot := cfg.WorkspaceRoot
|
||||
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Restore WorkspaceRoot — config file must not override path guards.
|
||||
cfg.WorkspaceRoot = savedRoot
|
||||
|
||||
// Clamp size/count limits so the config file cannot request excessive memory.
|
||||
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
|
||||
const maxAllowedSearchResults = 100000
|
||||
if cfg.MaxFileSize > maxAllowedSize {
|
||||
cfg.MaxFileSize = maxAllowedSize
|
||||
}
|
||||
if cfg.MaxParseSize > maxAllowedSize {
|
||||
cfg.MaxParseSize = maxAllowedSize
|
||||
}
|
||||
if cfg.MaxEditSize > maxAllowedSize {
|
||||
cfg.MaxEditSize = maxAllowedSize
|
||||
}
|
||||
if cfg.MaxSearchResults > maxAllowedSearchResults {
|
||||
cfg.MaxSearchResults = maxAllowedSearchResults
|
||||
}
|
||||
if cfg.MaxSearchResults < 0 {
|
||||
cfg.MaxSearchResults = 0 // negative would re-enable unbounded search
|
||||
}
|
||||
|
||||
// Override from environment variables
|
||||
cfg.loadFromEnv()
|
||||
@@ -169,6 +199,44 @@ func (c *Config) IsPathAllowed(path string) bool {
|
||||
|
||||
// Check if the path is within workspace (doesn't start with ..)
|
||||
// This prevents both "../" attacks and symlink bypasses
|
||||
// Also reject empty relative path (which means it's the workspace root itself)
|
||||
return rel != "." && !strings.HasPrefix(rel, "..")
|
||||
// The workspace root itself (rel == ".") is a valid, allowed path
|
||||
return !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
|
||||
// Validate validates the configuration and returns an error if invalid.
|
||||
// Checks include:
|
||||
// - MaxFileSize and MaxParseSize must be positive
|
||||
// - LSPTimeout must be positive
|
||||
// - WorkspaceRoot must exist (when not empty)
|
||||
func (c *Config) Validate() error {
|
||||
// Validate MaxFileSize
|
||||
if c.MaxFileSize <= 0 {
|
||||
return fmt.Errorf("max_file_size must be positive, got %d", c.MaxFileSize)
|
||||
}
|
||||
|
||||
// Validate MaxParseSize
|
||||
if c.MaxParseSize <= 0 {
|
||||
return fmt.Errorf("max_parse_size must be positive, got %d", c.MaxParseSize)
|
||||
}
|
||||
|
||||
// Validate LSPTimeout
|
||||
if c.LSPTimeout <= 0 {
|
||||
return fmt.Errorf("lsp_timeout must be positive, got %v", c.LSPTimeout)
|
||||
}
|
||||
|
||||
// Validate WorkspaceRoot exists
|
||||
if c.WorkspaceRoot != "" {
|
||||
info, err := os.Stat(c.WorkspaceRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("workspace_root does not exist: %s", c.WorkspaceRoot)
|
||||
}
|
||||
return fmt.Errorf("cannot access workspace_root: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("workspace_root is not a directory: %s", c.WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestLoad(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
@@ -108,7 +108,7 @@ func TestIsPathAllowed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg := Default()
|
||||
cfg.WorkspaceRoot = tmpDir
|
||||
@@ -156,7 +156,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Write config file
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
@@ -164,7 +164,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
"enable_lsp": false,
|
||||
"follow_symlinks": false
|
||||
}`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
@@ -182,3 +182,463 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
t.Error("expected FollowSymlinks to be false from config file")
|
||||
}
|
||||
}
|
||||
|
||||
// A config file requesting excessive size/count limits must be clamped so it
|
||||
// cannot force the server into unbounded memory use.
|
||||
func TestLoadClampsExcessiveLimits(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
configContent := `{
|
||||
"max_file_size": 999999999999,
|
||||
"max_parse_size": 999999999999,
|
||||
"max_edit_size": 999999999999,
|
||||
"max_search_results": 999999999
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
const maxSize int64 = 100 * 1024 * 1024
|
||||
if cfg.MaxFileSize > maxSize || cfg.MaxParseSize > maxSize || cfg.MaxEditSize > maxSize {
|
||||
t.Errorf("size limits not clamped: file=%d parse=%d edit=%d", cfg.MaxFileSize, cfg.MaxParseSize, cfg.MaxEditSize)
|
||||
}
|
||||
if cfg.MaxSearchResults > 100000 {
|
||||
t.Errorf("MaxSearchResults not clamped: %d", cfg.MaxSearchResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
path: tmpDir,
|
||||
expected: false, // Empty relative path
|
||||
expected: true, // Workspace root is a valid, allowed path (needed for ast_query)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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}
|
||||
// Marshalling a struct of two scalar fields (int, string) cannot fail, so the
|
||||
// error is intentionally ignored.
|
||||
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")
|
||||
}
|
||||
}
|
||||
+355
-229
@@ -6,37 +6,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
// Global regex cache for compiled patterns (thread-safe)
|
||||
var regexCache sync.Map // string -> *regexp.Regexp
|
||||
|
||||
// compileRegex compiles a regex pattern with caching for performance.
|
||||
func compileRegex(pattern string) (*regexp.Regexp, error) {
|
||||
// Check cache first
|
||||
if cached, ok := regexCache.Load(pattern); ok {
|
||||
return cached.(*regexp.Regexp), nil
|
||||
}
|
||||
|
||||
// Compile and cache
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
regexCache.Store(pattern, re)
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// EditOperation defines the type of edit operation.
|
||||
type EditOperation string
|
||||
|
||||
@@ -80,17 +61,48 @@ type EditResult struct {
|
||||
// Engine performs AST-aware edits.
|
||||
type Engine struct {
|
||||
registry *parser.Registry
|
||||
dmp *diffmatchpatch.DiffMatchPatch
|
||||
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
||||
// pathAllowed re-checks that a target path is admissible (within the
|
||||
// workspace root) immediately before a write. Injected to avoid coupling
|
||||
// edit to config; nil disables the check (used in unit tests).
|
||||
pathAllowed func(string) bool
|
||||
}
|
||||
|
||||
// NewEngine creates a new edit engine.
|
||||
// NewEngine creates a new edit engine with no write-time path validation.
|
||||
func NewEngine(registry *parser.Registry) *Engine {
|
||||
return NewEngineWithValidator(registry, nil)
|
||||
}
|
||||
|
||||
// NewEngineWithValidator creates an edit engine that re-validates each target
|
||||
// path with pathAllowed immediately before writing it, closing most of the
|
||||
// time-of-check/time-of-use window between the handler's admission check and the
|
||||
// actual write (a symlink swapped in afterward is rejected).
|
||||
func NewEngineWithValidator(registry *parser.Registry, pathAllowed func(string) bool) *Engine {
|
||||
return &Engine{
|
||||
registry: registry,
|
||||
fileLocks: sync.Map{},
|
||||
registry: registry,
|
||||
dmp: diffmatchpatch.New(),
|
||||
fileLocks: sync.Map{},
|
||||
pathAllowed: pathAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
// writeValidatedFile writes content to path, preserving the file's existing
|
||||
// permission bits, after re-checking that path is still admissible. The re-check
|
||||
// resolves symlinks at write time, so a symlink swapped in after the caller's
|
||||
// original validation cannot redirect the write outside the workspace root.
|
||||
func (e *Engine) writeValidatedFile(path string, content []byte) error {
|
||||
if e.pathAllowed != nil && !e.pathAllowed(path) {
|
||||
return errors.New(errors.ErrPathNotAllowed, "path is outside the workspace root").
|
||||
WithContext("path", path)
|
||||
}
|
||||
perm := os.FileMode(0o600) // default fallback when the file can't be stat'd
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
perm = info.Mode().Perm()
|
||||
}
|
||||
return os.WriteFile(path, content, perm)
|
||||
}
|
||||
|
||||
// lockFile acquires a lock for the specified file and returns an unlock function.
|
||||
// This prevents concurrent edits to the same file which could cause corruption.
|
||||
func (e *Engine) lockFile(filePath string) func() {
|
||||
@@ -186,19 +198,17 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
|
||||
}
|
||||
|
||||
// Generate diff
|
||||
diff := generateDiff(string(content), string(newContent), edit.File)
|
||||
diff := e.generateDiff(string(content), string(newContent), edit.File)
|
||||
|
||||
result := &EditResult{
|
||||
Success: true,
|
||||
Diff: diff,
|
||||
OriginalContent: string(content),
|
||||
NewContent: string(newContent),
|
||||
Applied: false,
|
||||
Success: true,
|
||||
Diff: diff,
|
||||
Applied: false,
|
||||
}
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -238,19 +248,17 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
|
||||
}
|
||||
|
||||
// Generate diff
|
||||
diff := generateDiff(string(content), string(newContent), edit.File)
|
||||
diff := e.generateDiff(string(content), string(newContent), edit.File)
|
||||
|
||||
result := &EditResult{
|
||||
Success: true,
|
||||
Diff: diff,
|
||||
OriginalContent: string(content),
|
||||
NewContent: string(newContent),
|
||||
Applied: false,
|
||||
Success: true,
|
||||
Diff: diff,
|
||||
Applied: false,
|
||||
}
|
||||
|
||||
// Apply changes if requested
|
||||
if apply {
|
||||
if err := os.WriteFile(edit.File, newContent, 0600); err != nil {
|
||||
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||
return &EditResult{
|
||||
Success: false,
|
||||
@@ -277,7 +285,11 @@ func (e *Engine) validateBaseEdit(edit *ASTEdit) error {
|
||||
switch edit.Operation {
|
||||
case EditReplace, EditInsertBefore, EditInsertAfter:
|
||||
if edit.NewContent == "" {
|
||||
return errors.NewInvalidEditError(fmt.Sprintf("new_content is required for %s operation", edit.Operation))
|
||||
msg := fmt.Sprintf("new_content is required for %s operation", edit.Operation)
|
||||
if edit.Operation == EditReplace {
|
||||
msg += " (to remove content instead, use operation=delete)"
|
||||
}
|
||||
return errors.NewInvalidEditError(msg)
|
||||
}
|
||||
case EditDelete:
|
||||
// new_content not required
|
||||
@@ -319,7 +331,7 @@ func (e *Engine) validateTextEdit(edit *ASTEdit) error {
|
||||
|
||||
// Validate regex pattern if provided (uses cached compilation)
|
||||
if edit.Selector.TextPattern != "" {
|
||||
if _, err := compileRegex(edit.Selector.TextPattern); err != nil {
|
||||
if _, err := util.CompileRegex(edit.Selector.TextPattern); err != nil {
|
||||
return errors.Wrap(errors.ErrInvalidEdit, "invalid text_pattern regex", err)
|
||||
}
|
||||
}
|
||||
@@ -352,6 +364,12 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
|
||||
return nil, errors.NewNodeNotFoundError(selectorDesc)
|
||||
}
|
||||
|
||||
// When using AtLine without a specific Kind, prefer the smallest (most specific) node.
|
||||
// This prevents matching large parent nodes like source_file when we want a specific declaration.
|
||||
if sel.AtLine > 0 && sel.Kind == "" {
|
||||
matches = sortBySpecificity(matches)
|
||||
}
|
||||
|
||||
// Use index to select specific match
|
||||
index := sel.Index
|
||||
if index < 0 || index >= len(matches) {
|
||||
@@ -361,6 +379,77 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
|
||||
return matches[index], nil
|
||||
}
|
||||
|
||||
// sortBySpecificity sorts nodes so that the most useful nodes come first.
|
||||
// Prefers: 1) Named nodes (declarations/statements) over anonymous tokens
|
||||
// 2) Smaller nodes over larger ones (more specific)
|
||||
func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
|
||||
if len(nodes) <= 1 {
|
||||
return nodes
|
||||
}
|
||||
|
||||
result := make([]*sitter.Node, len(nodes))
|
||||
copy(result, nodes)
|
||||
|
||||
slices.SortFunc(result, func(a, b *sitter.Node) int {
|
||||
if shouldPrefer(a, b) {
|
||||
return -1
|
||||
}
|
||||
if shouldPrefer(b, a) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// shouldPrefer returns true if node a should come before node b.
|
||||
func shouldPrefer(a, b *sitter.Node) bool {
|
||||
// Prefer named nodes over anonymous tokens
|
||||
aIsNamed := a.IsNamed()
|
||||
bIsNamed := b.IsNamed()
|
||||
if aIsNamed && !bIsNamed {
|
||||
return true
|
||||
}
|
||||
if !aIsNamed && bIsNamed {
|
||||
return false
|
||||
}
|
||||
|
||||
// Both named or both anonymous: prefer smaller meaningful nodes
|
||||
// But filter out very small nodes (likely just identifiers/literals)
|
||||
aSize := a.EndByte() - a.StartByte()
|
||||
bSize := b.EndByte() - b.StartByte()
|
||||
|
||||
// If both are named, prefer "declaration" or "statement" types
|
||||
aIsDecl := isDeclarationLike(a.Type())
|
||||
bIsDecl := isDeclarationLike(b.Type())
|
||||
if aIsDecl && !bIsDecl {
|
||||
return true
|
||||
}
|
||||
if !aIsDecl && bIsDecl {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same category: prefer smaller
|
||||
return aSize < bSize
|
||||
}
|
||||
|
||||
// isDeclarationLike returns true for node types that represent declarations or statements.
|
||||
func isDeclarationLike(nodeType string) bool {
|
||||
// Common declaration/statement patterns across languages
|
||||
declarationPatterns := []string{
|
||||
"declaration", "definition", "statement", "spec", "clause",
|
||||
"function", "method", "class", "struct", "interface", "type",
|
||||
"import", "package", "module", "const", "var", "let",
|
||||
}
|
||||
for _, pattern := range declarationPatterns {
|
||||
if strings.Contains(nodeType, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesSelector checks if a node matches the selector criteria.
|
||||
func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte) bool {
|
||||
// Check kind
|
||||
@@ -411,121 +500,255 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
|
||||
return true
|
||||
}
|
||||
|
||||
// applyEdit applies the edit operation to the content.
|
||||
// applyEdit applies an AST-mode edit. new_content is inserted verbatim — the caller
|
||||
// controls indentation — and only line endings are normalized to the file's convention.
|
||||
func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
|
||||
startByte := node.StartByte()
|
||||
endByte := node.EndByte()
|
||||
eol := detectLineEnding(content)
|
||||
newContent := normalizeLineEndings(edit.NewContent, eol)
|
||||
return spliceContent(edit.Operation, content, int(node.StartByte()), int(node.EndByte()), newContent, eol)
|
||||
}
|
||||
|
||||
// Detect and preserve indentation
|
||||
indentation := detectIndentation(content, startByte)
|
||||
newContent := indentContent(edit.NewContent, indentation)
|
||||
// detectLineEnding reports the file's dominant line-ending convention: "\r\n" when any
|
||||
// CRLF terminator is present, otherwise "\n".
|
||||
func detectLineEnding(content []byte) string {
|
||||
if bytes.Contains(content, []byte("\r\n")) {
|
||||
return "\r\n"
|
||||
}
|
||||
return "\n"
|
||||
}
|
||||
|
||||
// normalizeLineEndings rewrites every line ending in s to eol. It first collapses CRLF to
|
||||
// LF, then expands to the target, so mixed input becomes uniform and new_content can never
|
||||
// introduce a line ending foreign to the file being edited.
|
||||
func normalizeLineEndings(s, eol string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
if eol != "\n" {
|
||||
s = strings.ReplaceAll(s, "\n", eol)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func endsWithNewline(s string) bool { return strings.HasSuffix(s, "\n") }
|
||||
|
||||
func startsWithNewline(s string) bool {
|
||||
return s != "" && (s[0] == '\n' || s[0] == '\r')
|
||||
}
|
||||
|
||||
// spliceContent applies an edit operation by splicing newContent into content over the
|
||||
// byte range [start, end). It is shared by AST and text modes — once auto-indentation is
|
||||
// removed the two are identical. Restored terminators and separators use eol so the
|
||||
// file's line-ending convention is preserved.
|
||||
func spliceContent(op EditOperation, content []byte, start, end int, newContent, eol string) ([]byte, error) {
|
||||
// A line-based selection on a CRLF file can land `end` between the \r (treated as
|
||||
// line content) and the \n of a terminator. Pull it back so the full \r\n stays
|
||||
// intact in the tail and is never split into a bare LF.
|
||||
if end > start && end < len(content) && content[end-1] == '\r' && content[end] == '\n' {
|
||||
end--
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
switch edit.Operation {
|
||||
switch op {
|
||||
case EditReplace:
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, []byte(newContent)...)
|
||||
result = append(result, content[endByte:]...)
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, newContent...)
|
||||
// Restore a line terminator if the replaced range ended with one but the
|
||||
// replacement does not, to prevent merging with the following line.
|
||||
if end > start && content[end-1] == '\n' && !endsWithNewline(newContent) {
|
||||
result = append(result, eol...)
|
||||
}
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditInsertBefore:
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, []byte(newContent)...)
|
||||
result = append(result, '\n')
|
||||
result = append(result, content[startByte:]...)
|
||||
insertion := newContent
|
||||
if !endsWithNewline(insertion) {
|
||||
insertion += eol
|
||||
}
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, insertion...)
|
||||
result = append(result, content[start:]...)
|
||||
|
||||
case EditInsertAfter:
|
||||
result = append(result, content[:endByte]...)
|
||||
result = append(result, '\n')
|
||||
result = append(result, []byte(newContent)...)
|
||||
result = append(result, content[endByte:]...)
|
||||
insertion := newContent
|
||||
// Separate from preceding content.
|
||||
if end > 0 && content[end-1] != '\n' && !startsWithNewline(insertion) {
|
||||
insertion = eol + insertion
|
||||
}
|
||||
// Separate from following content.
|
||||
if !endsWithNewline(insertion) && end < len(content) && content[end] != '\n' {
|
||||
insertion += eol
|
||||
}
|
||||
result = append(result, content[:end]...)
|
||||
result = append(result, insertion...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditDelete:
|
||||
result = append(result, content[:startByte]...)
|
||||
result = append(result, content[endByte:]...)
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
default:
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", edit.Operation))
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// detectIndentation detects the indentation at a given byte position.
|
||||
func detectIndentation(content []byte, bytePos uint32) string {
|
||||
// Find the start of the line
|
||||
lineStart := int(bytePos)
|
||||
for lineStart > 0 && content[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
// noNewlineMarker is the git-style annotation emitted after a diff line whose source
|
||||
// version has no trailing newline.
|
||||
const noNewlineMarker = "\\ No newline at end of file\n"
|
||||
|
||||
// Extract leading whitespace
|
||||
var indent strings.Builder
|
||||
for i := lineStart; i < int(bytePos) && i < len(content); i++ {
|
||||
c := content[i]
|
||||
if c == ' ' || c == '\t' {
|
||||
indent.WriteByte(c)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return indent.String()
|
||||
// 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)
|
||||
}
|
||||
|
||||
// indentContent applies indentation to multi-line content.
|
||||
func indentContent(content string, indent string) string {
|
||||
if indent == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
if i > 0 && line != "" {
|
||||
lines[i] = indent + line
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
// indexRange is an inclusive [start, end] range of diffLine indices forming one hunk.
|
||||
type indexRange struct{ start, end int }
|
||||
|
||||
// generateDiff creates a unified diff between original and modified content.
|
||||
// Uses Myers diff algorithm for accurate and readable diffs.
|
||||
func generateDiff(original, modified, filename string) string {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(original, modified, false)
|
||||
// Uses a line-level Myers diff and outputs a unified diff with 3 lines of context
|
||||
// before/after each change, merging close hunks.
|
||||
func (e *Engine) generateDiff(original, modified, filename string) string {
|
||||
dmp := e.dmp
|
||||
|
||||
// Cleanup for readability
|
||||
// Line-level diffing: encode each line as a single rune, diff the encoded strings,
|
||||
// then decode back to real lines.
|
||||
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
|
||||
diffs := dmp.DiffCharsToLines(dmp.DiffMain(chars1, chars2, false), lineArray)
|
||||
diffs = dmp.DiffCleanupSemantic(diffs)
|
||||
|
||||
// Convert to unified diff format
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("--- %s\n", filename))
|
||||
buf.WriteString(fmt.Sprintf("+++ %s\n", filename))
|
||||
// Track whether each version lacks a final newline, so the diff is annotated
|
||||
// git-style ("\ No newline at end of file") instead of implying a phantom one.
|
||||
origNoEOL := len(original) > 0 && !strings.HasSuffix(original, "\n")
|
||||
modNoEOL := len(modified) > 0 && !strings.HasSuffix(modified, "\n")
|
||||
|
||||
// Group diffs into hunks
|
||||
lineNum := 1
|
||||
for _, diff := range diffs {
|
||||
lines := strings.Split(diff.Text, "\n")
|
||||
for i, line := range lines {
|
||||
// Skip empty last line from split
|
||||
if i == len(lines)-1 && line == "" {
|
||||
lines, maxOldN, maxNewN := flattenDiffLines(diffs)
|
||||
|
||||
ranges := diffHunkRanges(lines)
|
||||
if len(ranges) == 0 {
|
||||
return "" // no changes
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "--- %s\n", filename)
|
||||
fmt.Fprintf(&buf, "+++ %s\n", filename)
|
||||
for _, r := range ranges {
|
||||
oldStart, oldCount, newStart, newCount := hunkBounds(lines, r.start, r.end)
|
||||
fmt.Fprintf(&buf, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
|
||||
writeDiffBody(&buf, lines, r.start, r.end, origNoEOL, modNoEOL, maxOldN, maxNewN)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// flattenDiffLines expands diff segments into per-line records with 1-based line numbers,
|
||||
// returning the lines plus the final line number of each version (for no-newline marking).
|
||||
func flattenDiffLines(diffs []diffmatchpatch.Diff) (lines []diffLine, maxOldN, maxNewN int) {
|
||||
oldLine, newLine := 1, 1
|
||||
for _, d := range diffs {
|
||||
for _, raw := range strings.SplitAfter(d.Text, "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffDelete:
|
||||
buf.WriteString(fmt.Sprintf("-%s\n", line))
|
||||
case diffmatchpatch.DiffInsert:
|
||||
buf.WriteString(fmt.Sprintf("+%s\n", line))
|
||||
// Strip the terminator for display; also drop a trailing CR so CRLF files
|
||||
// do not leak raw carriage returns into the rendered diff.
|
||||
text := strings.TrimSuffix(strings.TrimSuffix(raw, "\n"), "\r")
|
||||
// Record both running line numbers on every line (not just the active
|
||||
// side): the opposite-side counter is the position the change sits at,
|
||||
// which hunkBounds needs to compute correct @@ header start lines.
|
||||
switch d.Type {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
buf.WriteString(fmt.Sprintf(" %s\n", line))
|
||||
lineNum++
|
||||
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, newN: newLine})
|
||||
oldLine++
|
||||
case diffmatchpatch.DiffInsert:
|
||||
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
|
||||
newLine++
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines, oldLine - 1, newLine - 1
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
// diffHunkRanges returns the inclusive index ranges to emit: each changed line padded by
|
||||
// 3 lines of context, with overlapping/adjacent ranges merged.
|
||||
func diffHunkRanges(lines []diffLine) []indexRange {
|
||||
const contextSize = 3
|
||||
var ranges []indexRange
|
||||
for i, l := range lines {
|
||||
if l.op == diffmatchpatch.DiffEqual {
|
||||
continue
|
||||
}
|
||||
rStart := max(i-contextSize, 0)
|
||||
rEnd := min(i+contextSize, len(lines)-1)
|
||||
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
|
||||
ranges[len(ranges)-1].end = rEnd // merge with previous
|
||||
} else {
|
||||
ranges = append(ranges, indexRange{rStart, rEnd})
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// hunkBounds computes the unified-diff hunk header line numbers and counts for
|
||||
// lines[start:end+1]. Each line carries both running line numbers (see
|
||||
// flattenDiffLines), so the hunk start is simply the first line's numbers. When a
|
||||
// side has zero lines in the hunk (pure insertion or deletion), the header start
|
||||
// for that side follows GNU's convention: the line number *before* the change,
|
||||
// clamped to 0 at the start of the file (e.g. "@@ -0,0 +1,N @@").
|
||||
func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
|
||||
oldStart = lines[start].oldN
|
||||
newStart = lines[start].newN
|
||||
for i := start; i <= end; i++ {
|
||||
switch lines[i].op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
oldCount++
|
||||
newCount++
|
||||
case diffmatchpatch.DiffDelete:
|
||||
oldCount++
|
||||
case diffmatchpatch.DiffInsert:
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
if oldCount == 0 {
|
||||
oldStart = max(0, oldStart-1)
|
||||
}
|
||||
if newCount == 0 {
|
||||
newStart = max(0, newStart-1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// writeDiffBody writes the space/-/+ body lines for one hunk, appending the git-style
|
||||
// no-newline marker after the final line of any version that lacks a trailing newline.
|
||||
func writeDiffBody(buf *bytes.Buffer, lines []diffLine, start, end int, origNoEOL, modNoEOL bool, maxOldN, maxNewN int) {
|
||||
for i := start; i <= end; i++ {
|
||||
l := lines[i]
|
||||
switch l.op {
|
||||
case diffmatchpatch.DiffEqual:
|
||||
fmt.Fprintf(buf, " %s\n", l.text)
|
||||
if (origNoEOL && l.oldN == maxOldN) || (modNoEOL && l.newN == maxNewN) {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
case diffmatchpatch.DiffDelete:
|
||||
fmt.Fprintf(buf, "-%s\n", l.text)
|
||||
if origNoEOL && l.oldN == maxOldN {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
case diffmatchpatch.DiffInsert:
|
||||
fmt.Fprintf(buf, "+%s\n", l.text)
|
||||
if modNoEOL && l.newN == maxNewN {
|
||||
buf.WriteString(noNewlineMarker)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTextSelector finds the byte range for a text-based selection.
|
||||
@@ -569,24 +792,6 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text not found: %q", truncateString(text, 50)))
|
||||
}
|
||||
|
||||
// If multiple matches and no index specified, require explicit selection
|
||||
if len(matches) > 1 && index == 0 {
|
||||
// Check if index was explicitly set to 0 or just defaulted
|
||||
// Since we can't distinguish, we'll allow index 0 but warn about multiple matches
|
||||
// Actually, let's be strict and require explicit index for multiple matches
|
||||
locations := make([]string, 0, min(len(matches), 5))
|
||||
for i, m := range matches {
|
||||
if i >= 5 {
|
||||
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
|
||||
break
|
||||
}
|
||||
line := countLines(content[:m.start]) + 1
|
||||
locations = append(locations, fmt.Sprintf("line %d", line))
|
||||
}
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("text matches %d locations (%s); use selector_index to specify which one (0-%d)",
|
||||
len(matches), strings.Join(locations, ", "), len(matches)-1))
|
||||
}
|
||||
|
||||
if index >= len(matches) {
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
||||
}
|
||||
@@ -596,7 +801,7 @@ func (e *Engine) findExactText(content []byte, text string, index int) (start, e
|
||||
|
||||
// findRegexPattern finds a regex pattern match in content.
|
||||
func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (start, end int, err error) {
|
||||
re, err := compileRegex(pattern)
|
||||
re, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
return 0, 0, errors.Wrap(errors.ErrInvalidEdit, "invalid regex pattern", err)
|
||||
}
|
||||
@@ -606,21 +811,6 @@ func (e *Engine) findRegexPattern(content []byte, pattern string, index int) (st
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern not found: %q", truncateString(pattern, 50)))
|
||||
}
|
||||
|
||||
// If multiple matches and index is 0 (default), show error with locations
|
||||
if len(matches) > 1 && index == 0 {
|
||||
locations := make([]string, 0, min(len(matches), 5))
|
||||
for i, m := range matches {
|
||||
if i >= 5 {
|
||||
locations = append(locations, fmt.Sprintf("... and %d more", len(matches)-5))
|
||||
break
|
||||
}
|
||||
line := countLines(content[:m[0]]) + 1
|
||||
locations = append(locations, fmt.Sprintf("line %d", line))
|
||||
}
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("pattern matches %d locations (%s); use selector_index to specify which one (0-%d)",
|
||||
len(matches), strings.Join(locations, ", "), len(matches)-1))
|
||||
}
|
||||
|
||||
if index >= len(matches) {
|
||||
return 0, 0, errors.NewInvalidSelectionError(fmt.Sprintf("selector_index %d out of range (found %d matches)", index, len(matches)))
|
||||
}
|
||||
@@ -643,6 +833,10 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
||||
}
|
||||
|
||||
lines := bytes.Split(content, []byte("\n"))
|
||||
// Trim phantom empty element from trailing newline
|
||||
if len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
totalLines := len(lines)
|
||||
|
||||
// Convert to 0-indexed
|
||||
@@ -658,7 +852,7 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
||||
|
||||
// Calculate byte positions
|
||||
start = 0
|
||||
for i := 0; i < startIdx; i++ {
|
||||
for i := range startIdx {
|
||||
start += len(lines[i]) + 1 // +1 for newline
|
||||
}
|
||||
|
||||
@@ -673,63 +867,11 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// applyTextEditOperation applies a text edit operation.
|
||||
// applyTextEditOperation applies a text-mode edit. Like AST mode, new_content is inserted
|
||||
// verbatim (no auto-indentation) with its line endings normalized to the file's convention.
|
||||
func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
|
||||
// Detect indentation at the selection point
|
||||
indentation := detectIndentationAtByte(content, start)
|
||||
indentedContent := indentContent(newContent, indentation)
|
||||
|
||||
var result []byte
|
||||
|
||||
switch op {
|
||||
case EditReplace:
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, []byte(indentedContent)...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditInsertBefore:
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, []byte(indentedContent)...)
|
||||
result = append(result, '\n')
|
||||
result = append(result, content[start:]...)
|
||||
|
||||
case EditInsertAfter:
|
||||
result = append(result, content[:end]...)
|
||||
result = append(result, '\n')
|
||||
result = append(result, []byte(indentedContent)...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
case EditDelete:
|
||||
result = append(result, content[:start]...)
|
||||
result = append(result, content[end:]...)
|
||||
|
||||
default:
|
||||
return nil, errors.NewInvalidEditError(fmt.Sprintf("unknown operation: %s", op))
|
||||
}
|
||||
|
||||
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()
|
||||
eol := detectLineEnding(content)
|
||||
return spliceContent(op, content, start, end, normalizeLineEndings(newContent, eol), eol)
|
||||
}
|
||||
|
||||
// truncateString truncates a string to maxLen with ellipsis.
|
||||
@@ -737,21 +879,5 @@ func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// countLines counts the number of newlines in content.
|
||||
func countLines(content []byte) int {
|
||||
return bytes.Count(content, []byte("\n"))
|
||||
}
|
||||
|
||||
// ValidateLanguage checks if AST editing is supported for a file.
|
||||
// Returns nil for supported languages, error for unsupported.
|
||||
// Note: Text-based editing is always available regardless of this check.
|
||||
func ValidateLanguage(filename string) error {
|
||||
lang := protocol.DetectLanguage(filename)
|
||||
if lang == protocol.LangUnknown {
|
||||
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
|
||||
}
|
||||
return nil
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
+1100
-39
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
package edit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
)
|
||||
|
||||
// mkEngine builds an Engine with a registry that is closed at test end.
|
||||
func mkEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
reg := parser.NewRegistry()
|
||||
t.Cleanup(reg.Close)
|
||||
return NewEngine(reg)
|
||||
}
|
||||
|
||||
// applyEditFile writes content to a temp file, applies the edit, and returns the on-disk bytes.
|
||||
func applyEditFile(t *testing.T, name, content string, e *ASTEdit) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
e.File = p
|
||||
res, err := mkEngine(t).Apply(context.Background(), e)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("apply unsuccessful: %s", res.Error)
|
||||
}
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("readback: %v", err)
|
||||
}
|
||||
return string(got)
|
||||
}
|
||||
|
||||
// assertAllCRLF fails if s contains any bare LF (not preceded by CR) or a doubled CR.
|
||||
func assertAllCRLF(t *testing.T, s string) {
|
||||
t.Helper()
|
||||
if strings.Count(s, "\n") != strings.Count(s, "\r\n") {
|
||||
t.Fatalf("file contains bare LF (mixed line endings): %q", s)
|
||||
}
|
||||
if strings.Contains(s, "\r\r") {
|
||||
t.Fatalf("file contains doubled CR: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster C: text-mode edits insert new_content verbatim (no auto-indentation) ----
|
||||
|
||||
func TestTextEditVerbatimNoAutoIndent(t *testing.T) {
|
||||
// selector_text matches a token that sits at one tab of indentation, so the old
|
||||
// code detected "\t" and re-indented continuation lines of new_content. Because
|
||||
// new_content is itself already indented, that produced a DOUBLE tab on line 2.
|
||||
src := "func f() {\n\tOLD\n}\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "A\n\tB", // line 2 already carries its own tab
|
||||
Selector: ASTSelector{Text: "OLD"},
|
||||
})
|
||||
want := "func f() {\n\tA\n\tB\n}\n"
|
||||
if got != want {
|
||||
t.Fatalf("text edit must insert new_content verbatim (no auto-indent).\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster B: edits preserve the file's original line-ending convention ----
|
||||
|
||||
func TestASTEditPreservesCRLF(t *testing.T) {
|
||||
src := "package main\r\n\r\nfunc demo() {\r\n\tprintln(\"old\")\r\n}\r\n"
|
||||
got := applyEditFile(t, "demo.go", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "func demo() {\n\treturn\n}",
|
||||
Selector: ASTSelector{Kind: "function_declaration", Name: "demo"},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
if !strings.Contains(got, "func demo() {\r\n\treturn\r\n}") {
|
||||
t.Fatalf("replacement not normalized to CRLF: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEditReplacePreservesCRLF(t *testing.T) {
|
||||
src := "alpha\r\nbravo\r\ncharlie\r\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO1\nBRAVO2",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
want := "alpha\r\nBRAVO1\r\nBRAVO2\r\ncharlie\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("CRLF text replace.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEditInsertAfterPreservesCRLF(t *testing.T) {
|
||||
src := "alpha\r\nbravo\r\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditInsertAfter,
|
||||
NewContent: "INSERTED",
|
||||
Selector: ASTSelector{AtLine: 1, LineEnd: 1},
|
||||
})
|
||||
assertAllCRLF(t, got)
|
||||
want := "alpha\r\nINSERTED\r\nbravo\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("CRLF insert_after.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLFFileStaysLFWhenNewContentHasCRLF(t *testing.T) {
|
||||
src := "alpha\nbravo\ncharlie\n"
|
||||
got := applyEditFile(t, "f.txt", src, &ASTEdit{
|
||||
Operation: EditReplace,
|
||||
NewContent: "B1\r\nB2", // stray CRLF in new_content must be normalized to the LF file
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if strings.Contains(got, "\r") {
|
||||
t.Fatalf("LF file must not gain CR: %q", got)
|
||||
}
|
||||
want := "alpha\nB1\nB2\ncharlie\n"
|
||||
if got != want {
|
||||
t.Fatalf("LF normalization.\nwant: %q\ngot: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cluster D: diff rendering ----
|
||||
|
||||
func TestDiffMarksNoNewlineAtEOF(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(p, []byte("alpha\nbravo"), 0o600); err != nil { // no trailing newline
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
|
||||
File: p,
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("preview failed: %v %s", err, res.Error)
|
||||
}
|
||||
if !strings.Contains(res.Diff, "No newline at end of file") {
|
||||
t.Fatalf("diff should mark missing final newline, got:\n%s", res.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffHasNoRawCarriageReturn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(p, []byte("alpha\r\nbravo\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
res, err := mkEngine(t).Preview(context.Background(), &ASTEdit{
|
||||
File: p,
|
||||
Operation: EditReplace,
|
||||
NewContent: "BRAVO",
|
||||
Selector: ASTSelector{AtLine: 2, LineEnd: 2},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("preview failed: %v %s", err, res.Error)
|
||||
}
|
||||
if strings.Contains(res.Diff, "\r") {
|
||||
t.Fatalf("diff display must not contain raw CR, got:\n%q", res.Diff)
|
||||
}
|
||||
}
|
||||
+111
-3
@@ -16,6 +16,16 @@ import (
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// ProcessKillTimeout is the timeout for waiting for a process to exit before force killing.
|
||||
const ProcessKillTimeout = 5 * time.Second
|
||||
|
||||
// StderrBufferSize is the buffer size for draining stderr.
|
||||
const StderrBufferSize = 1024
|
||||
|
||||
// StderrCaptureMax is the maximum number of recent stderr bytes retained for
|
||||
// diagnostics (e.g. to explain an init failure). Older bytes are dropped.
|
||||
const StderrCaptureMax = 4096
|
||||
|
||||
// Client represents an LSP client connection.
|
||||
type Client struct {
|
||||
stdin io.WriteCloser
|
||||
@@ -29,6 +39,8 @@ type Client struct {
|
||||
runningMu sync.RWMutex
|
||||
stopOnce sync.Once
|
||||
mu sync.Mutex
|
||||
stderrMu sync.Mutex
|
||||
stderrTail []byte // most recent stderr, capped at StderrCaptureMax (diagnostics)
|
||||
running bool
|
||||
}
|
||||
|
||||
@@ -104,12 +116,60 @@ func NewClient(cmd *exec.Cmd) (*Client, error) {
|
||||
notifications: make(chan *Notification, 100),
|
||||
}
|
||||
|
||||
// Start reader goroutine
|
||||
// Start reader goroutine for stdout
|
||||
go c.readLoop()
|
||||
|
||||
// Start stderr drain goroutine to prevent pipe buffer from filling up
|
||||
go c.drainStderr()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// drainStderr consumes stderr output to prevent the LSP server from blocking.
|
||||
// LSP servers may write diagnostic messages to stderr which we discard.
|
||||
//
|
||||
// Cancellation note: the goroutine parks in the blocking stderr.Read below, so
|
||||
// closing c.done does not interrupt it directly — termination relies on the
|
||||
// read returning an error when the pipe reaches EOF (the process is killed on
|
||||
// Close). The c.done check is only a best-effort fast path for the case where
|
||||
// done is already closed between reads.
|
||||
func (c *Client) drainStderr() {
|
||||
buf := make([]byte, StderrBufferSize)
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
n, err := c.stderr.Read(buf)
|
||||
if n > 0 {
|
||||
c.captureStderr(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureStderr appends p to the retained stderr tail, keeping at most
|
||||
// StderrCaptureMax bytes (dropping the oldest).
|
||||
func (c *Client) captureStderr(p []byte) {
|
||||
c.stderrMu.Lock()
|
||||
defer c.stderrMu.Unlock()
|
||||
c.stderrTail = append(c.stderrTail, p...)
|
||||
if len(c.stderrTail) > StderrCaptureMax {
|
||||
c.stderrTail = c.stderrTail[len(c.stderrTail)-StderrCaptureMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// RecentStderr returns the retained tail of the server's stderr, trimmed. Useful
|
||||
// for surfacing why an LSP server failed to start.
|
||||
func (c *Client) RecentStderr() string {
|
||||
c.stderrMu.Lock()
|
||||
defer c.stderrMu.Unlock()
|
||||
return strings.TrimSpace(string(c.stderrTail))
|
||||
}
|
||||
|
||||
// Call sends a request and waits for a response.
|
||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||
c.runningMu.RLock()
|
||||
@@ -208,7 +268,7 @@ func (c *Client) Close() error {
|
||||
select {
|
||||
case <-done:
|
||||
// Clean exit
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-time.After(ProcessKillTimeout):
|
||||
// Force kill
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
@@ -241,7 +301,16 @@ func (c *Client) send(msg interface{}) error {
|
||||
}
|
||||
|
||||
// readLoop reads and dispatches messages from the server.
|
||||
// On exit (for any reason), it drains all pending Call waiters with a
|
||||
// synthetic error so that goroutines blocked in Call are unblocked.
|
||||
func (c *Client) readLoop() {
|
||||
// On exit (EOF/error from a crashed or closed server) mark the client not
|
||||
// running so subsequent Calls fail fast — otherwise they pass the running
|
||||
// guard, send to a dead stdin, and block until their own timeout. Then
|
||||
// unblock any already-in-flight waiters. LIFO: setRunning(false) runs first.
|
||||
defer c.drainPending()
|
||||
defer c.setRunning(false)
|
||||
|
||||
reader := bufio.NewReader(c.stdout)
|
||||
|
||||
for {
|
||||
@@ -284,7 +353,14 @@ func (c *Client) readLoop() {
|
||||
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
|
||||
c.mu.Lock()
|
||||
if ch, ok := c.pending[resp.ID]; ok {
|
||||
ch <- &resp
|
||||
// Non-blocking send: the pending channel is buffered(1) and read
|
||||
// exactly once. A misbehaving server that sends a duplicate
|
||||
// response for the same id must not block here while holding c.mu
|
||||
// (which would deadlock all future dispatches).
|
||||
select {
|
||||
case ch <- &resp:
|
||||
default:
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
@@ -302,9 +378,41 @@ 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 {
|
||||
// Non-blocking, matching the dispatch path: the channel is buffered(1)
|
||||
// and read once, but a duplicate-id race could have already filled it —
|
||||
// never block here while holding c.mu.
|
||||
select {
|
||||
case ch <- &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &ResponseError{
|
||||
Code: -32603, // InternalError
|
||||
Message: "LSP client readLoop terminated",
|
||||
},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning returns whether the client is running.
|
||||
func (c *Client) IsRunning() bool {
|
||||
c.runningMu.RLock()
|
||||
defer c.runningMu.RUnlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
// setRunning sets the running flag under its lock.
|
||||
func (c *Client) setRunning(v bool) {
|
||||
c.runningMu.Lock()
|
||||
c.running = v
|
||||
c.runningMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClientMarksNotRunningOnProcessExit verifies crash detection: when the
|
||||
// underlying server process exits (stdout EOF), readLoop must flip the client to
|
||||
// not-running so later Calls fail fast instead of blocking until their timeout.
|
||||
func TestClientMarksNotRunningOnProcessExit(t *testing.T) {
|
||||
cmd := exec.Command("sh", "-c", "exit 0") // exits immediately, closing stdout
|
||||
c, err := NewClient(cmd)
|
||||
if err != nil {
|
||||
t.Skipf("cannot start helper process: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if c.IsRunning() {
|
||||
t.Fatal("client should be marked not running after its process exits")
|
||||
}
|
||||
|
||||
// A Call on the dead client must fail fast (the running guard), not hang.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, callErr := c.Call(context.Background(), "test/method", nil)
|
||||
done <- callErr
|
||||
}()
|
||||
select {
|
||||
case callErr := <-done:
|
||||
if callErr == nil {
|
||||
t.Error("Call on a dead client should return an error")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Call on a dead client hung instead of failing fast")
|
||||
}
|
||||
}
|
||||
|
||||
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
|
||||
// must keep recent bytes and never grow past StderrCaptureMax.
|
||||
func TestClientStderrCapture(t *testing.T) {
|
||||
c := &Client{}
|
||||
|
||||
if c.RecentStderr() != "" {
|
||||
t.Errorf("expected empty stderr initially, got %q", c.RecentStderr())
|
||||
}
|
||||
|
||||
c.captureStderr([]byte("gopls: command not found\n"))
|
||||
if !strings.Contains(c.RecentStderr(), "command not found") {
|
||||
t.Errorf("expected captured stderr, got %q", c.RecentStderr())
|
||||
}
|
||||
|
||||
// Exceed the cap: only the most recent StderrCaptureMax bytes are kept.
|
||||
big := strings.Repeat("x", StderrCaptureMax*2)
|
||||
c.captureStderr([]byte(big))
|
||||
if got := len(c.RecentStderr()); got > StderrCaptureMax {
|
||||
t.Errorf("stderr tail not bounded: len=%d, want <= %d", got, StderrCaptureMax)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseDefinitionResult covers every LSP definition result shape: single
|
||||
// Location, Location[], single LocationLink, LocationLink[], plus empty/error
|
||||
// cases. The LocationLink shapes are the regression target — we advertise
|
||||
// LinkSupport, so gopls/rust-analyzer return targetUri/targetRange, which a
|
||||
// naive []Location unmarshal would silently turn into zero-value locations.
|
||||
func TestParseDefinitionResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURIs []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single Location",
|
||||
raw: `{"uri":"file:///a.go","range":{"start":{"line":4,"character":2},"end":{"line":4,"character":8}}}`,
|
||||
wantURIs: []string{"file:///a.go"},
|
||||
},
|
||||
{
|
||||
name: "Location array",
|
||||
raw: `[{"uri":"file:///a.go","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}},{"uri":"file:///b.go","range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}}}]`,
|
||||
wantURIs: []string{"file:///a.go", "file:///b.go"},
|
||||
},
|
||||
{
|
||||
name: "single LocationLink",
|
||||
raw: `{"targetUri":"file:///c.go","targetRange":{"start":{"line":9,"character":0},"end":{"line":12,"character":1}}}`,
|
||||
wantURIs: []string{"file:///c.go"},
|
||||
},
|
||||
{
|
||||
name: "LocationLink array",
|
||||
raw: `[{"targetUri":"file:///d.go","targetRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}}}]`,
|
||||
wantURIs: []string{"file:///d.go"},
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
raw: `[]`,
|
||||
wantURIs: []string{},
|
||||
},
|
||||
{
|
||||
name: "garbage",
|
||||
raw: `"not-a-location"`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
locs, err := parseDefinitionResult(json.RawMessage(tt.raw))
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got locations %+v", locs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(locs) != len(tt.wantURIs) {
|
||||
t.Fatalf("got %d locations, want %d (%+v)", len(locs), len(tt.wantURIs), locs)
|
||||
}
|
||||
for i, want := range tt.wantURIs {
|
||||
if locs[i].URI != want {
|
||||
t.Errorf("location[%d].URI = %q, want %q", i, locs[i].URI, want)
|
||||
}
|
||||
}
|
||||
// LocationLink ranges must be carried over, not zeroed.
|
||||
if tt.name == "single LocationLink" && locs[0].Range.Start.Line != 9 {
|
||||
t.Errorf("LocationLink targetRange not preserved: got start line %d, want 9", locs[0].Range.Start.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+182
-48
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -15,7 +16,22 @@ import (
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// LSP timeout and interval constants.
|
||||
const (
|
||||
// DefaultLSPTimeout is the default timeout for LSP requests.
|
||||
DefaultLSPTimeout = 10 * time.Second
|
||||
// DefaultIdleTimeout is the duration before idle LSP servers are reaped.
|
||||
DefaultIdleTimeout = 5 * time.Minute
|
||||
// ReaperInterval is how often the idle server reaper runs.
|
||||
ReaperInterval = 60 * time.Second
|
||||
// ShutdownTimeout is the timeout for graceful LSP server shutdown.
|
||||
ShutdownTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Manager manages LSP servers for different languages.
|
||||
//
|
||||
// Lock ordering: m.mu must always be acquired before srv.mu.
|
||||
// Never acquire m.mu while holding srv.mu.
|
||||
type Manager struct {
|
||||
servers map[protocol.Language]*ManagedServer
|
||||
logger *slog.Logger
|
||||
@@ -24,6 +40,7 @@ type Manager struct {
|
||||
timeout time.Duration
|
||||
idleTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
closeOnce sync.Once
|
||||
stopped bool
|
||||
}
|
||||
|
||||
@@ -68,14 +85,32 @@ var DefaultServerConfigs = map[protocol.Language]ServerConfig{
|
||||
protocol.LangCpp: {
|
||||
Command: []string{"clangd"},
|
||||
},
|
||||
protocol.LangRust: {
|
||||
Command: []string{"rust-analyzer"},
|
||||
},
|
||||
}
|
||||
|
||||
// AllowedLSPBinaries is a whitelist of allowed LSP server binary names.
|
||||
// This prevents command injection by ensuring only known LSP servers can be executed.
|
||||
var AllowedLSPBinaries = map[string]bool{
|
||||
"gopls": true,
|
||||
"typescript-language-server": true,
|
||||
"pylsp": true,
|
||||
"clangd": true,
|
||||
// Common alternatives
|
||||
"tsserver": true,
|
||||
"pyright": true,
|
||||
"ruff-lsp": true,
|
||||
"rust-analyzer": true,
|
||||
"ccls": true,
|
||||
}
|
||||
|
||||
// NewManager creates a new LSP manager.
|
||||
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
servers: make(map[protocol.Language]*ManagedServer),
|
||||
timeout: 10 * time.Second,
|
||||
idleTimeout: 5 * time.Minute,
|
||||
timeout: DefaultLSPTimeout,
|
||||
idleTimeout: DefaultIdleTimeout,
|
||||
workspaceRoot: workspaceRoot,
|
||||
logger: logger,
|
||||
stopReaper: make(chan struct{}),
|
||||
@@ -93,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
srv, exists := m.servers[lang]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists && srv.ready {
|
||||
// A cached server is only reusable if it is both initialized and still
|
||||
// running — a crashed language server stays in the map with ready==true, so
|
||||
// without the liveness check every later call would be routed to a dead
|
||||
// process and block until timeout.
|
||||
if exists && srv.ready && srv.client.IsRunning() {
|
||||
// Update lastUsed with server's own lock to avoid race condition
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
@@ -106,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready {
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready && srv.client.IsRunning() {
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
srv.mu.Unlock()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Evict a stale entry (crashed or never-ready) before spawning a replacement.
|
||||
// Its process has already exited, so close it asynchronously to avoid holding
|
||||
// m.mu on the (fast) cmd.Wait().
|
||||
if stale, ok := m.servers[lang]; ok {
|
||||
delete(m.servers, lang)
|
||||
go func() { _ = stale.client.Close() }()
|
||||
}
|
||||
|
||||
// Check if server config exists
|
||||
config, ok := DefaultServerConfigs[lang]
|
||||
if !ok {
|
||||
@@ -127,12 +174,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
return nil, errors.NewLSPServerNotFound(string(lang), config.Command[0])
|
||||
}
|
||||
|
||||
// Create command
|
||||
// Validate command against whitelist to prevent command injection
|
||||
binaryName := filepath.Base(cmdPath)
|
||||
if !AllowedLSPBinaries[binaryName] {
|
||||
return nil, errors.New(errors.ErrLSPServerNotFound, fmt.Sprintf("LSP binary %q is not in the allowed list", binaryName)).
|
||||
WithContext("language", string(lang)).
|
||||
WithContext("binary", binaryName).
|
||||
WithRemediation("Only whitelisted LSP server binaries are allowed for security reasons")
|
||||
}
|
||||
|
||||
// Create command — use exec.Command (not CommandContext) so the LSP
|
||||
// subprocess is not killed when the request-scoped context expires.
|
||||
args := append(config.Command[1:], config.Args...)
|
||||
cmd := exec.CommandContext(ctx, cmdPath, args...)
|
||||
cmd := exec.Command(cmdPath, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = m.workspaceRoot
|
||||
|
||||
// Create client
|
||||
client, err := NewClient(cmd)
|
||||
if err != nil {
|
||||
@@ -153,20 +209,36 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
||||
openDocs: make(map[string]int),
|
||||
}
|
||||
|
||||
// Setup cleanup on failure - ensures resources are freed if initialization fails
|
||||
var initialized bool
|
||||
defer func() {
|
||||
if !initialized {
|
||||
_ = client.Close()
|
||||
// Ensure process is killed on initialization failure
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize server
|
||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
||||
_ = client.Close()
|
||||
// Ensure process is killed on initialization failure
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
newSrv.initErr = err
|
||||
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
||||
wrapped := errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
||||
WithContext("language", string(lang)).
|
||||
WithContext("command", config.Command[0]).
|
||||
WithRemediation("Check LSP server logs for initialization errors")
|
||||
// Surface the server's own stderr (e.g. "command not found", missing
|
||||
// module) so the failure is diagnosable instead of opaque.
|
||||
if stderr := client.RecentStderr(); stderr != "" {
|
||||
wrapped = wrapped.WithContext("stderr", stderr)
|
||||
}
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
// Mark as successfully initialized to prevent cleanup
|
||||
initialized = true
|
||||
|
||||
newSrv.ready = true
|
||||
m.servers[lang] = newSrv
|
||||
m.logger.Info("started LSP server", "language", lang, "command", config.Command[0])
|
||||
@@ -304,17 +376,55 @@ func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Result can be Location, []Location, or []LocationLink
|
||||
var locations []Location
|
||||
if err := json.Unmarshal(resp.Result, &locations); err != nil {
|
||||
// Try single location
|
||||
var single Location
|
||||
if err := json.Unmarshal(resp.Result, &single); err == nil {
|
||||
locations = []Location{single}
|
||||
return parseDefinitionResult(resp.Result)
|
||||
}
|
||||
|
||||
// parseDefinitionResult decodes a textDocument/definition result. Per LSP the
|
||||
// result may be Location, Location[], LocationLink, or LocationLink[]. Because
|
||||
// we advertise LinkSupport (see initialize), servers such as gopls and
|
||||
// rust-analyzer return the LocationLink shape (targetUri/targetRange). A naive
|
||||
// unmarshal into []Location silently succeeds with zero-value URIs/ranges, so
|
||||
// decode into a unified shape that captures both and normalize to Location.
|
||||
func parseDefinitionResult(raw json.RawMessage) ([]Location, error) {
|
||||
// locOrLink captures both the Location (uri/range) and LocationLink
|
||||
// (targetUri/targetRange) field sets; whichever is populated wins.
|
||||
type locOrLink struct {
|
||||
URI string `json:"uri"`
|
||||
Range Range `json:"range"`
|
||||
TargetURI string `json:"targetUri"`
|
||||
TargetRange Range `json:"targetRange"`
|
||||
}
|
||||
toLocation := func(l locOrLink) (Location, bool) {
|
||||
if l.URI != "" {
|
||||
return Location{URI: l.URI, Range: l.Range}, true
|
||||
}
|
||||
if l.TargetURI != "" {
|
||||
return Location{URI: l.TargetURI, Range: l.TargetRange}, true
|
||||
}
|
||||
return Location{}, false
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
// Array form (Location[] or LocationLink[]).
|
||||
var arr []locOrLink
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
locations := make([]Location, 0, len(arr))
|
||||
for _, l := range arr {
|
||||
if loc, ok := toLocation(l); ok {
|
||||
locations = append(locations, loc)
|
||||
}
|
||||
}
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
// Single object form (Location or LocationLink).
|
||||
var single locOrLink
|
||||
if err := json.Unmarshal(raw, &single); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse definition result: %w", err)
|
||||
}
|
||||
if loc, ok := toLocation(single); ok {
|
||||
return []Location{loc}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// References finds all references to the symbol at the given position.
|
||||
@@ -367,9 +477,13 @@ func (m *Manager) References(ctx context.Context, file string, line, col int, in
|
||||
}
|
||||
|
||||
// ensureDocumentOpen opens a document if not already open.
|
||||
func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, file string) error {
|
||||
// It reads the file content outside the lock (to avoid holding the lock during I/O),
|
||||
// then holds srv.mu for the entire check-and-send sequence to prevent duplicate didOpen
|
||||
// notifications from concurrent goroutines.
|
||||
func (m *Manager) ensureDocumentOpen(_ context.Context, srv *ManagedServer, file string) error {
|
||||
uri := fileToURI(file)
|
||||
|
||||
// Quick check under lock — common fast path.
|
||||
srv.mu.Lock()
|
||||
if _, ok := srv.openDocs[uri]; ok {
|
||||
srv.mu.Unlock()
|
||||
@@ -377,15 +491,23 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
|
||||
}
|
||||
srv.mu.Unlock()
|
||||
|
||||
// Read file content
|
||||
// Read file content outside the lock to avoid holding it during I/O.
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Get language ID
|
||||
langID := languageToLSPID(srv.language)
|
||||
|
||||
// Re-acquire lock and re-check to prevent TOCTOU race: two goroutines could
|
||||
// both pass the fast-path check above and both try to send didOpen.
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
|
||||
if _, ok := srv.openDocs[uri]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
params := DidOpenTextDocumentParams{
|
||||
TextDocument: TextDocumentItem{
|
||||
URI: uri,
|
||||
@@ -399,10 +521,7 @@ func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, fi
|
||||
return fmt.Errorf("didOpen failed: %w", err)
|
||||
}
|
||||
|
||||
srv.mu.Lock()
|
||||
srv.openDocs[uri] = 1
|
||||
srv.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -437,7 +556,7 @@ func (m *Manager) CloseDocument(_ context.Context, lang protocol.Language, file
|
||||
|
||||
// reapIdleServers periodically closes idle servers.
|
||||
func (m *Manager) reapIdleServers() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
ticker := time.NewTicker(ReaperInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
@@ -445,6 +564,10 @@ func (m *Manager) reapIdleServers() {
|
||||
case <-m.stopReaper:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Collect idle servers first to avoid holding the lock while closing
|
||||
var toClose []*ManagedServer
|
||||
var toCloseLanguages []protocol.Language
|
||||
|
||||
m.mu.Lock()
|
||||
for lang, srv := range m.servers {
|
||||
// Check lastUsed with server's lock to avoid race condition
|
||||
@@ -453,36 +576,45 @@ func (m *Manager) reapIdleServers() {
|
||||
srv.mu.Unlock()
|
||||
|
||||
if idle {
|
||||
m.logger.Info("closing idle LSP server", "language", lang)
|
||||
_ = srv.client.Close()
|
||||
toClose = append(toClose, srv)
|
||||
toCloseLanguages = append(toCloseLanguages, lang)
|
||||
delete(m.servers, lang)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Close servers outside the lock to prevent deadlock
|
||||
// (Close can block waiting for the process to exit)
|
||||
for i, srv := range toClose {
|
||||
m.logger.Info("closing idle LSP server", "language", toCloseLanguages[i])
|
||||
_ = srv.client.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down all LSP servers.
|
||||
// Close shuts down all LSP servers. It is safe to call multiple times.
|
||||
func (m *Manager) Close() error {
|
||||
close(m.stopReaper)
|
||||
m.closeOnce.Do(func() {
|
||||
close(m.stopReaper)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.stopped = true
|
||||
m.stopped = true
|
||||
|
||||
for lang, srv := range m.servers {
|
||||
m.logger.Info("shutting down LSP server", "language", lang)
|
||||
// Try graceful shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, _ = srv.client.Call(ctx, "shutdown", nil)
|
||||
cancel()
|
||||
_ = srv.client.Notify("exit", nil)
|
||||
_ = srv.client.Close()
|
||||
}
|
||||
for lang, srv := range m.servers {
|
||||
m.logger.Info("shutting down LSP server", "language", lang)
|
||||
// Try graceful shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
|
||||
_, _ = srv.client.Call(ctx, "shutdown", nil)
|
||||
cancel()
|
||||
_ = srv.client.Notify("exit", nil)
|
||||
_ = srv.client.Close()
|
||||
}
|
||||
|
||||
m.servers = make(map[protocol.Language]*ManagedServer)
|
||||
m.servers = make(map[protocol.Language]*ManagedServer)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -497,13 +629,13 @@ func (m *Manager) IsAvailable(lang protocol.Language) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// fileToURI converts a file path to a file URI.
|
||||
// fileToURI converts a file path to a properly percent-encoded file URI.
|
||||
func fileToURI(file string) string {
|
||||
absPath, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return "file://" + file
|
||||
absPath = file
|
||||
}
|
||||
return "file://" + absPath
|
||||
return (&url.URL{Scheme: "file", Path: absPath}).String()
|
||||
}
|
||||
|
||||
// URIToFile converts a file URI to a file path.
|
||||
@@ -529,6 +661,8 @@ func languageToLSPID(lang protocol.Language) string {
|
||||
return "c"
|
||||
case protocol.LangCpp:
|
||||
return "cpp"
|
||||
case protocol.LangRust:
|
||||
return "rust"
|
||||
default:
|
||||
return string(lang)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
@@ -110,3 +114,250 @@ func TestDefaultServerConfigs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerTimeout tests timeout handling in LSP operations.
|
||||
func TestManagerTimeout(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
// Verify timeout is set
|
||||
if manager.timeout == 0 {
|
||||
t.Error("manager timeout should not be zero")
|
||||
}
|
||||
|
||||
// Verify default timeout is reasonable
|
||||
if manager.timeout != 10*time.Second {
|
||||
t.Errorf("expected default timeout of 10s, got %v", manager.timeout)
|
||||
}
|
||||
|
||||
// Test that manager can handle short timeouts
|
||||
manager.timeout = 1 * time.Millisecond
|
||||
|
||||
// Create a context that will timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Try to get a server with very short timeout - this should fail quickly
|
||||
// Use a language that doesn't have an LSP server installed
|
||||
_, err := manager.GetServer(ctx, "invalid_language")
|
||||
if err == nil {
|
||||
t.Log("GetServer with invalid language succeeded (LSP server may be installed)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerConnectionFailure tests handling of LSP connection failures.
|
||||
func TestManagerConnectionFailure(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test 1: Invalid language
|
||||
_, err := manager.GetServer(ctx, "nonexistent_language")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent language")
|
||||
}
|
||||
|
||||
// Test 2: Try to use LSP features without a valid server
|
||||
// This should fail gracefully
|
||||
_, err = manager.Hover(ctx, "/tmp/test.fake", 1, 1)
|
||||
if err == nil {
|
||||
t.Error("expected error for hover on unsupported language")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerGracefulShutdown tests graceful shutdown of LSP servers.
|
||||
func TestManagerGracefulShutdown(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
|
||||
// Close should not panic even with no servers started
|
||||
err := manager.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify manager is stopped
|
||||
if !manager.stopped {
|
||||
t.Error("manager should be marked as stopped after Close()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerDoubleClose verifies that calling Close() twice does not panic (T-05, C-02).
|
||||
func TestManagerDoubleClose(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
|
||||
// First close should succeed
|
||||
err := manager.Close()
|
||||
if err != nil {
|
||||
t.Errorf("first Close() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Second close must not panic (C-02 fix wraps close in sync.Once)
|
||||
err = manager.Close()
|
||||
if err != nil {
|
||||
t.Errorf("second Close() returned error: %v", err)
|
||||
}
|
||||
|
||||
if !manager.stopped {
|
||||
t.Error("manager should be marked as stopped after double Close()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerIdleReaper tests the idle server cleanup mechanism.
|
||||
func TestManagerIdleReaper(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
|
||||
// Set a very short idle timeout for testing
|
||||
manager.idleTimeout = 100 * time.Millisecond
|
||||
|
||||
// Verify idle timeout is set correctly
|
||||
if manager.idleTimeout != 100*time.Millisecond {
|
||||
t.Errorf("expected idle timeout of 100ms, got %v", manager.idleTimeout)
|
||||
}
|
||||
|
||||
// The reaper goroutine should be running
|
||||
// We can't easily test it without actually starting LSP servers,
|
||||
// but we can verify it doesn't panic on close
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
err := manager.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() with active reaper returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerDocumentManagement tests document open/close operations.
|
||||
func TestManagerDocumentManagement(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test closing a document for a non-existent server
|
||||
err := manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
|
||||
if err != nil {
|
||||
t.Errorf("CloseDocument on non-existent server should not error: %v", err)
|
||||
}
|
||||
|
||||
// Test closing a document that was never opened
|
||||
err = manager.CloseDocument(ctx, protocol.LangGo, "/tmp/test.go")
|
||||
if err != nil {
|
||||
t.Errorf("CloseDocument on unopened document should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerConcurrentAccess tests concurrent access to the manager.
|
||||
func TestManagerConcurrentAccess(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
// Test concurrent IsAvailable calls
|
||||
const numGoroutines = 10
|
||||
done := make(chan bool, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("panic in concurrent IsAvailable: %v", r)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Call IsAvailable multiple times
|
||||
for j := 0; j < 5; j++ {
|
||||
_ = manager.IsAvailable(protocol.LangGo)
|
||||
_ = manager.IsAvailable(protocol.LangPython)
|
||||
_ = manager.IsAvailable(protocol.LangTypeScript)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerErrorHandling tests various error conditions.
|
||||
func TestManagerErrorHandling(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
testFunc func() error
|
||||
}{
|
||||
{
|
||||
name: "hover_on_nonexistent_file",
|
||||
testFunc: func() error {
|
||||
_, err := manager.Hover(ctx, "/nonexistent/file.go", 1, 1)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "definition_on_nonexistent_file",
|
||||
testFunc: func() error {
|
||||
_, err := manager.Definition(ctx, "/nonexistent/file.go", 1, 1)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "references_on_nonexistent_file",
|
||||
testFunc: func() error {
|
||||
_, err := manager.References(ctx, "/nonexistent/file.go", 1, 1, true)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.testFunc()
|
||||
if err == nil {
|
||||
t.Log("operation succeeded (LSP server may be handling gracefully)")
|
||||
}
|
||||
// We don't require an error because behavior depends on whether
|
||||
// the LSP server is installed and how it handles missing files
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerWorkspaceRoot tests workspace root handling.
|
||||
func TestManagerWorkspaceRoot(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
manager := NewManager(tmpDir, logger)
|
||||
t.Cleanup(func() { _ = manager.Close() })
|
||||
|
||||
if manager.workspaceRoot != tmpDir {
|
||||
t.Errorf("expected workspace root %s, got %s", tmpDir, manager.workspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
// 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. Counters are monotonic, so a
|
||||
// negative delta (which would corrupt rate()-style calculations downstream) is
|
||||
// ignored rather than applied.
|
||||
func (c *Counter) Add(delta int64) {
|
||||
if delta < 0 {
|
||||
return
|
||||
}
|
||||
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 != "" {
|
||||
fmt.Fprintf(&sb, "# HELP %s %s\n", c.name, c.help)
|
||||
}
|
||||
fmt.Fprintf(&sb, "# TYPE %s counter\n", c.name)
|
||||
fmt.Fprintf(&sb, "%s%s %d\n", c.name, formatLabels(c.labels), c.value.Load())
|
||||
}
|
||||
|
||||
// Export gauges
|
||||
for _, g := range r.gauges {
|
||||
if g.help != "" {
|
||||
fmt.Fprintf(&sb, "# HELP %s %s\n", g.name, g.help)
|
||||
}
|
||||
fmt.Fprintf(&sb, "# TYPE %s gauge\n", g.name)
|
||||
fmt.Fprintf(&sb, "%s%s %d\n", g.name, formatLabels(g.labels), g.value.Load())
|
||||
}
|
||||
|
||||
// Export histograms
|
||||
for _, h := range r.histograms {
|
||||
if h.help != "" {
|
||||
fmt.Fprintf(&sb, "# HELP %s %s\n", h.name, h.help)
|
||||
}
|
||||
fmt.Fprintf(&sb, "# 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 == "" {
|
||||
fmt.Fprintf(&sb, "%s_bucket{le=\"%g\"} %d\n", h.name, bound, cumulative)
|
||||
} else {
|
||||
// Insert le label into existing labels
|
||||
fmt.Fprintf(&sb, "%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 == "" {
|
||||
fmt.Fprintf(&sb, "%s_bucket{le=\"+Inf\"} %d\n", h.name, cumulative)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "%s_bucket%s %d\n", h.name,
|
||||
strings.Replace(labelStr, "}", ",le=\"+Inf\"}", 1), cumulative)
|
||||
}
|
||||
|
||||
// Sum and count
|
||||
fmt.Fprintf(&sb, "%s_sum%s %g\n", h.name, formatLabels(h.labels), h.Sum())
|
||||
fmt.Fprintf(&sb, "%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,571 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Counters are monotonic: a negative Add must be ignored, not applied.
|
||||
func TestCounterRejectsNegativeAdd(t *testing.T) {
|
||||
c := NewCounter("mono_counter", "test", nil)
|
||||
c.Add(5)
|
||||
c.Add(-3)
|
||||
if got := c.Value(); got != 5 {
|
||||
t.Errorf("negative Add should be ignored: Value() = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
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" }
|
||||
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Positions are 1-indexed; reject non-positive values so the uint32
|
||||
// conversion below can't underflow to a huge Row/Column (line/col == 0
|
||||
// would wrap to 4294967295). Reachable from the LSP-down AST fallback with
|
||||
// caller-supplied coordinates.
|
||||
if line < 1 || col < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to 0-indexed
|
||||
point := sitter.Point{
|
||||
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNodeAtPosition(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
content := []byte("package main\n\nfunc Hello() string { return \"hi\" }\n")
|
||||
res, err := r.Parse(context.Background(), "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
t.Run("nil tree", func(t *testing.T) {
|
||||
if FindNodeAtPosition(nil, 3, 6) != nil {
|
||||
t.Error("nil tree must return nil")
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against the uint32 underflow: non-positive coordinates must return
|
||||
// nil, never wrap to a huge Row/Column.
|
||||
t.Run("non-positive coordinates", func(t *testing.T) {
|
||||
for _, c := range []struct{ line, col int }{{0, 0}, {0, 5}, {3, 0}, {-1, 4}} {
|
||||
if got := FindNodeAtPosition(res.Tree, c.line, c.col); got != nil {
|
||||
t.Errorf("FindNodeAtPosition(tree, %d, %d) = %v, want nil", c.line, c.col, got.Type())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out of range line returns nil", func(t *testing.T) {
|
||||
if got := FindNodeAtPosition(res.Tree, 99999, 1); got != nil {
|
||||
t.Errorf("far-out-of-range line should return nil, got %v", got.Type())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid position returns a node", func(t *testing.T) {
|
||||
// Line 3, column 6 lands inside "Hello".
|
||||
got := FindNodeAtPosition(res.Tree, 3, 6)
|
||||
if got == nil {
|
||||
t.Fatal("expected a node at a valid in-range position, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
// TestLRUCacheEviction tests that the LRU cache properly evicts old entries.
|
||||
@@ -82,8 +84,8 @@ func TestContentHashCollisionResistance(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hash1 := contentHash(tc.content1)
|
||||
hash2 := contentHash(tc.content2)
|
||||
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content1))
|
||||
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(tc.content2))
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Errorf("Hash collision: %s == %s for different content", hash1, hash2)
|
||||
@@ -96,9 +98,9 @@ func TestContentHashCollisionResistance(t *testing.T) {
|
||||
func TestContentHashConsistency(t *testing.T) {
|
||||
content := []byte("package main\n\nfunc test() {}\n")
|
||||
|
||||
hash1 := contentHash(content)
|
||||
hash2 := contentHash(content)
|
||||
hash3 := contentHash(content)
|
||||
hash1 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||
hash2 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||
hash3 := fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||
|
||||
if hash1 != hash2 || hash2 != hash3 {
|
||||
t.Errorf("Hash inconsistency: %s, %s, %s", hash1, hash2, hash3)
|
||||
@@ -115,7 +117,7 @@ func BenchmarkContentHash_xxHash(b *testing.B) {
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = contentHash(content)
|
||||
_ = fmt.Sprintf("%016x", xxhash.Sum64(content))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
@@ -46,6 +47,10 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
|
||||
return extractPythonDocComment(n, content)
|
||||
case protocol.LangC, protocol.LangCpp:
|
||||
return extractCDocComment(n, content)
|
||||
case protocol.LangElixir:
|
||||
return extractElixirDocComment(n, content)
|
||||
case protocol.LangRust:
|
||||
return extractRustDocComment(n, content)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -526,11 +531,16 @@ func FormatDocComment(doc *DocComment) string {
|
||||
written[t] = true
|
||||
}
|
||||
|
||||
for tagName, val := range doc.Tags {
|
||||
remaining := make([]string, 0, len(doc.Tags))
|
||||
for tagName := range doc.Tags {
|
||||
if !written[tagName] {
|
||||
sb.WriteString("@" + tagName + " " + val + "\n")
|
||||
remaining = append(remaining, tagName)
|
||||
}
|
||||
}
|
||||
sort.Strings(remaining) // deterministic output (map order is random)
|
||||
for _, tagName := range remaining {
|
||||
sb.WriteString("@" + tagName + " " + doc.Tags[tagName] + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
@@ -548,3 +558,167 @@ func cleanPythonDocstring(doc string) string {
|
||||
|
||||
return strings.TrimSpace(doc)
|
||||
}
|
||||
|
||||
// extractRustDocComment extracts Rust documentation comments (/// style).
|
||||
func extractRustDocComment(n *sitter.Node, content []byte) *DocComment {
|
||||
comments := collectPrecedingComments(n, content, []string{"line_comment"})
|
||||
if len(comments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter for /// doc comments only
|
||||
var docComments []*sitter.Node
|
||||
for _, c := range comments {
|
||||
text := GetNodeText(c, content)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if strings.HasPrefix(trimmed, "///") {
|
||||
docComments = append(docComments, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(docComments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var raw []string
|
||||
startLine := -1
|
||||
endLine := -1
|
||||
|
||||
for _, c := range docComments {
|
||||
text := GetNodeText(c, content)
|
||||
raw = append(raw, text)
|
||||
|
||||
if startLine == -1 {
|
||||
startLine = int(c.StartPoint().Row) + 1
|
||||
}
|
||||
endLine = int(c.EndPoint().Row) + 1
|
||||
|
||||
// Clean /// prefix
|
||||
cleaned := strings.TrimSpace(text)
|
||||
cleaned = strings.TrimPrefix(cleaned, "///")
|
||||
if len(cleaned) > 0 && cleaned[0] == ' ' {
|
||||
cleaned = cleaned[1:]
|
||||
}
|
||||
parts = append(parts, cleaned)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &DocComment{
|
||||
Text: strings.Join(parts, "\n"),
|
||||
Raw: strings.Join(raw, "\n"),
|
||||
Style: CommentStyleDoxygen,
|
||||
Tags: nil,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
|
||||
// Elixir uses module attributes like @doc and @moduledoc for documentation.
|
||||
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
|
||||
// Look for @doc or @moduledoc attribute preceding this node
|
||||
prev := n.PrevSibling()
|
||||
|
||||
for prev != nil {
|
||||
// Check if this is an unary_operator with @ (module attribute)
|
||||
if prev.Type() == "unary_operator" {
|
||||
text := GetNodeText(prev, content)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
|
||||
// Check for @doc or @moduledoc
|
||||
if strings.HasPrefix(trimmed, "@doc") || strings.HasPrefix(trimmed, "@moduledoc") {
|
||||
// Extract the documentation string
|
||||
docText := extractElixirDocString(prev, content)
|
||||
if docText != "" {
|
||||
return &DocComment{
|
||||
Text: docText,
|
||||
Raw: text,
|
||||
Style: CommentStyleDocstring,
|
||||
Tags: nil,
|
||||
StartLine: int(prev.StartPoint().Row) + 1,
|
||||
EndLine: int(prev.EndPoint().Row) + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for regular # comments
|
||||
if prev.Type() == "comment" {
|
||||
comments := collectPrecedingComments(n, content, []string{"comment"})
|
||||
if len(comments) > 0 {
|
||||
var parts []string
|
||||
var raw []string
|
||||
startLine := -1
|
||||
endLine := -1
|
||||
|
||||
for _, c := range comments {
|
||||
text := GetNodeText(c, content)
|
||||
raw = append(raw, text)
|
||||
|
||||
if startLine == -1 {
|
||||
startLine = int(c.StartPoint().Row) + 1
|
||||
}
|
||||
endLine = int(c.EndPoint().Row) + 1
|
||||
|
||||
// Clean # comment
|
||||
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "#"))
|
||||
if cleaned != "" {
|
||||
parts = append(parts, cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
return &DocComment{
|
||||
Text: strings.Join(parts, "\n"),
|
||||
Raw: strings.Join(raw, "\n"),
|
||||
Style: CommentStyleHash,
|
||||
Tags: nil,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
prev = prev.PrevSibling()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElixirDocString extracts the documentation string from an Elixir @doc/@moduledoc attribute.
|
||||
func extractElixirDocString(n *sitter.Node, content []byte) string {
|
||||
// The doc attribute typically looks like:
|
||||
// @doc """
|
||||
// Documentation here
|
||||
// """
|
||||
// or
|
||||
// @doc "Single line doc"
|
||||
|
||||
text := GetNodeText(n, content)
|
||||
|
||||
// Find the string content after @doc or @moduledoc
|
||||
var docContent string
|
||||
|
||||
// Check for heredoc style (triple quotes)
|
||||
if idx := strings.Index(text, `"""`); idx != -1 {
|
||||
// Find the closing triple quotes
|
||||
rest := text[idx+3:]
|
||||
if endIdx := strings.Index(rest, `"""`); endIdx != -1 {
|
||||
docContent = rest[:endIdx]
|
||||
}
|
||||
} else if idx := strings.Index(text, `"`); idx != -1 {
|
||||
// Single quoted string
|
||||
rest := text[idx+1:]
|
||||
if endIdx := strings.Index(rest, `"`); endIdx != -1 {
|
||||
docContent = rest[:endIdx]
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(docContent)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func NoComment() {}
|
||||
|
||||
if doc == nil {
|
||||
t.Fatal("expected doc, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if doc.Text != tt.wantText {
|
||||
@@ -192,6 +193,7 @@ function validate(name) {}
|
||||
|
||||
if doc == nil {
|
||||
t.Fatal("expected doc, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if doc.Text != tt.wantText {
|
||||
@@ -291,6 +293,7 @@ func TestExtractPythonDocComment(t *testing.T) {
|
||||
|
||||
if doc == nil {
|
||||
t.Fatal("expected doc, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if doc.Text != tt.wantText {
|
||||
@@ -372,6 +375,7 @@ int simple() { return 1; }
|
||||
|
||||
if doc == nil {
|
||||
t.Fatal("expected doc, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if doc.Text != tt.wantText {
|
||||
|
||||
+85
-28
@@ -5,37 +5,56 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
"github.com/smacker/go-tree-sitter/c"
|
||||
"github.com/smacker/go-tree-sitter/cpp"
|
||||
"github.com/smacker/go-tree-sitter/elixir"
|
||||
"github.com/smacker/go-tree-sitter/golang"
|
||||
"github.com/smacker/go-tree-sitter/html"
|
||||
"github.com/smacker/go-tree-sitter/javascript"
|
||||
"github.com/smacker/go-tree-sitter/python"
|
||||
"github.com/smacker/go-tree-sitter/rust"
|
||||
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// MaxFileSize is the maximum file size we'll parse (10MB).
|
||||
const MaxFileSize = 10 * 1024 * 1024
|
||||
// maxFileSize is the default maximum file size we'll parse (10MB).
|
||||
const maxFileSize = 10 * 1024 * 1024
|
||||
|
||||
// Registry manages Tree-sitter parsers for different languages.
|
||||
type Registry struct {
|
||||
parsers map[protocol.Language]*sitter.Parser
|
||||
cache *lru.Cache[string, *CachedTree]
|
||||
mu sync.RWMutex
|
||||
parsers map[protocol.Language]*sitter.Parser
|
||||
cache *lru.Cache[string, *CachedTree]
|
||||
maxParseSize int64
|
||||
mu sync.RWMutex
|
||||
parserMu sync.Map // per-language mutexes for parse serialization
|
||||
closed bool // Indicates if the registry has been closed
|
||||
|
||||
// Cache metrics (atomic for thread-safety)
|
||||
cacheHits atomic.Int64
|
||||
cacheMisses atomic.Int64
|
||||
|
||||
// Parse duration tracking
|
||||
totalParseTime atomic.Int64 // nanoseconds
|
||||
parseCount atomic.Int64
|
||||
lastParseDuration atomic.Int64 // nanoseconds
|
||||
}
|
||||
|
||||
// CachedTree stores a parsed tree with its metadata.
|
||||
// Content is not stored to reduce memory usage.
|
||||
// Content is not stored to reduce memory usage. Syntax errors are a pure
|
||||
// function of the tree (extractErrors ignores content), so they are cached
|
||||
// alongside it to avoid re-walking the whole tree on every cache hit.
|
||||
type CachedTree struct {
|
||||
Tree *sitter.Tree
|
||||
Language protocol.Language
|
||||
Errors []SyntaxError
|
||||
}
|
||||
|
||||
// ParseResult contains the result of parsing a file.
|
||||
@@ -53,8 +72,15 @@ type SyntaxError struct {
|
||||
Location protocol.Location
|
||||
}
|
||||
|
||||
// NewRegistry creates a new parser registry.
|
||||
// NewRegistry creates a new parser registry with the default max parse size.
|
||||
// For custom max parse size, use NewRegistryWithSize.
|
||||
func NewRegistry() *Registry {
|
||||
return NewRegistryWithSize(0)
|
||||
}
|
||||
|
||||
// NewRegistryWithSize creates a new parser registry with the specified max parse size.
|
||||
// If maxParseSize is 0 or negative, uses the default MaxFileSize constant.
|
||||
func NewRegistryWithSize(maxParseSize int64) *Registry {
|
||||
// Create LRU cache with capacity of 100 trees
|
||||
cache, err := lru.New[string, *CachedTree](100)
|
||||
if err != nil {
|
||||
@@ -62,9 +88,14 @@ func NewRegistry() *Registry {
|
||||
panic(fmt.Sprintf("failed to create LRU cache: %v", err))
|
||||
}
|
||||
|
||||
if maxParseSize <= 0 {
|
||||
maxParseSize = maxFileSize
|
||||
}
|
||||
|
||||
return &Registry{
|
||||
parsers: make(map[protocol.Language]*sitter.Parser),
|
||||
cache: cache,
|
||||
parsers: make(map[protocol.Language]*sitter.Parser),
|
||||
cache: cache,
|
||||
maxParseSize: maxParseSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +119,25 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
|
||||
case protocol.LangVue:
|
||||
// Vue SFC files use HTML-like template syntax, so we use the HTML parser
|
||||
return html.GetLanguage(), nil
|
||||
case protocol.LangElixir:
|
||||
return elixir.GetLanguage(), nil
|
||||
case protocol.LangRust:
|
||||
return rust.GetLanguage(), nil
|
||||
default:
|
||||
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
|
||||
WithContext("language", string(lang)).
|
||||
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue")
|
||||
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, Rust")
|
||||
}
|
||||
}
|
||||
|
||||
// GetParser returns a parser for the given language.
|
||||
// Returns an error if the registry has been closed.
|
||||
func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
||||
r.mu.RLock()
|
||||
if r.closed {
|
||||
r.mu.RUnlock()
|
||||
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
|
||||
}
|
||||
if p, ok := r.parsers[lang]; ok {
|
||||
r.mu.RUnlock()
|
||||
return p, nil
|
||||
@@ -108,6 +148,11 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Check closed again after acquiring write lock
|
||||
if r.closed {
|
||||
return nil, errors.New(errors.ErrInternal, "parser registry is closed")
|
||||
}
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if p, ok := r.parsers[lang]; ok {
|
||||
return p, nil
|
||||
@@ -127,9 +172,9 @@ func (r *Registry) GetParser(lang protocol.Language) (*sitter.Parser, error) {
|
||||
|
||||
// Parse parses the given content for the specified language.
|
||||
func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > MaxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
||||
// Check file size against configured limit
|
||||
if int64(len(content)) > r.maxParseSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), r.maxParseSize)
|
||||
}
|
||||
|
||||
// Detect binary files
|
||||
@@ -156,16 +201,18 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
||||
}
|
||||
|
||||
// Check cache (LRU cache is thread-safe)
|
||||
hash := contentHash(content)
|
||||
// Include language in cache key to prevent cross-language collisions
|
||||
hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content))
|
||||
if cached, ok := r.cache.Get(hash); ok && cached.Language == lang {
|
||||
errors := extractErrors(cached.Tree.RootNode(), content)
|
||||
r.cacheHits.Add(1)
|
||||
return &ParseResult{
|
||||
Tree: cached.Tree,
|
||||
Language: lang,
|
||||
Errors: errors,
|
||||
Errors: cached.Errors,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
r.cacheMisses.Add(1)
|
||||
|
||||
// Get parser
|
||||
parser, err := r.GetParser(lang)
|
||||
@@ -173,11 +220,22 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse content - tree-sitter parsers are not thread-safe,
|
||||
// so we need to hold the lock during parsing
|
||||
r.mu.Lock()
|
||||
// Parse content - tree-sitter parsers are not thread-safe per instance,
|
||||
// but parsers for different languages are independent.
|
||||
// Use per-language locks to allow concurrent parsing of different languages.
|
||||
muVal, _ := r.parserMu.LoadOrStore(lang, &sync.Mutex{})
|
||||
langMu := muVal.(*sync.Mutex)
|
||||
|
||||
start := time.Now()
|
||||
langMu.Lock()
|
||||
tree, err := parser.ParseCtx(ctx, nil, content)
|
||||
r.mu.Unlock()
|
||||
langMu.Unlock()
|
||||
duration := time.Since(start)
|
||||
|
||||
// Update duration metrics
|
||||
r.totalParseTime.Add(duration.Nanoseconds())
|
||||
r.parseCount.Add(1)
|
||||
r.lastParseDuration.Store(duration.Nanoseconds())
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.NewParseError(string(lang), filename, err)
|
||||
@@ -186,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) (
|
||||
// Extract syntax errors
|
||||
errors := extractErrors(tree.RootNode(), content)
|
||||
|
||||
// Cache result (LRU cache handles eviction automatically)
|
||||
// Cache result (LRU cache handles eviction automatically).
|
||||
// Errors are cached so cache hits skip a full tree walk.
|
||||
r.cache.Add(hash, &CachedTree{
|
||||
Tree: tree,
|
||||
Language: lang,
|
||||
Errors: errors,
|
||||
})
|
||||
|
||||
return &ParseResult{
|
||||
@@ -236,13 +296,6 @@ func extractErrors(node *sitter.Node, _ []byte) []SyntaxError {
|
||||
return errors
|
||||
}
|
||||
|
||||
// contentHash returns a fast hash of the content for caching.
|
||||
// Uses xxHash which is 5-10x faster than SHA256 for non-cryptographic purposes.
|
||||
func contentHash(content []byte) string {
|
||||
h := xxhash.Sum64(content)
|
||||
return fmt.Sprintf("%016x", h)
|
||||
}
|
||||
|
||||
// isBinary checks if content appears to be binary.
|
||||
func isBinary(content []byte) bool {
|
||||
// Check first 8000 bytes for null bytes
|
||||
@@ -257,10 +310,14 @@ func isBinary(content []byte) bool {
|
||||
}
|
||||
|
||||
// Close closes all parsers and clears the cache.
|
||||
// After Close is called, the registry cannot be used for parsing.
|
||||
func (r *Registry) Close() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Mark as closed first to prevent new parse operations
|
||||
r.closed = true
|
||||
|
||||
for _, p := range r.parsers {
|
||||
p.Close()
|
||||
}
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -172,6 +172,43 @@ func TestParseWithSyntaxErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCachedErrorsMatch verifies that the syntax errors returned on a
|
||||
// cache hit are identical to those computed on the initial parse. Errors are
|
||||
// cached alongside the tree (they are a pure function of the tree), so the hot
|
||||
// cache-hit path must not lose or alter them.
|
||||
func TestParseCachedErrorsMatch(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
content := []byte("package main\n\nfunc main( {}\n") // missing closing paren
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := r.Parse(ctx, "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("first parse failed: %v", err)
|
||||
}
|
||||
if len(first.Errors) == 0 {
|
||||
t.Fatal("expected syntax errors on first parse")
|
||||
}
|
||||
|
||||
second, err := r.Parse(ctx, "test.go", content)
|
||||
if err != nil {
|
||||
t.Fatalf("cached parse failed: %v", err)
|
||||
}
|
||||
if second.Tree != first.Tree {
|
||||
t.Fatal("expected cache hit (same tree pointer)")
|
||||
}
|
||||
|
||||
if len(second.Errors) != len(first.Errors) {
|
||||
t.Fatalf("cached errors count = %d, want %d", len(second.Errors), len(first.Errors))
|
||||
}
|
||||
for i := range first.Errors {
|
||||
if second.Errors[i] != first.Errors[i] {
|
||||
t.Errorf("cached error[%d] = %+v, want %+v", i, second.Errors[i], first.Errors[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBinary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// 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, lang), false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// renderSymbolsOnly renders a compact symbol list (fallback for unsupported
|
||||
// languages). It deliberately does not fabricate "<source line> { ... }" output:
|
||||
// the raw start line plus a guessed brace is wrong for brace-less languages (e.g.
|
||||
// Elixir's "def f do", HTML) and only wastes tokens. The output is not valid
|
||||
// source, so it drops "// " comment framing and uses a dense "kind name :line"
|
||||
// form to minimise tokens. The symbol list (kind, name, line, doc) is accurate.
|
||||
func renderSymbolsOnly(syms []protocol.Symbol, lang protocol.Language) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "symbols only (no skeleton for %s):\n", lang)
|
||||
for _, s := range syms {
|
||||
fmt.Fprintf(&sb, "%s %s :%d\n", s.Kind, s.Name, s.Location.Line)
|
||||
if s.Doc != "" {
|
||||
fmt.Fprintf(&sb, " doc: %s\n", s.Doc)
|
||||
}
|
||||
}
|
||||
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 "decorated_definition":
|
||||
// Emit the decorator line(s), then render the wrapped definition so its
|
||||
// signature (def/class) is preserved — taking lines[0] of the whole node
|
||||
// would keep only the first decorator and drop the signature entirely.
|
||||
var def *sitter.Node
|
||||
for i := 0; i < int(node.NamedChildCount()); i++ {
|
||||
if child := node.NamedChild(i); child != nil {
|
||||
if t := child.Type(); t == "function_definition" || t == "class_definition" {
|
||||
def = child
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if def != nil {
|
||||
decText := strings.TrimRight(string(content[node.StartByte():def.StartByte()]), "\n")
|
||||
for _, dl := range strings.Split(decText, "\n") {
|
||||
if strings.TrimSpace(dl) == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(indent)
|
||||
sb.WriteString(dl)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
skeletonPythonNode(def, content, sb, indent)
|
||||
return
|
||||
}
|
||||
fallthrough
|
||||
case "function_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,48 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A decorated Python function must keep BOTH its decorator(s) and its def
|
||||
// signature in the skeleton — previously only the first decorator survived and
|
||||
// the signature was dropped.
|
||||
func TestSkeletonPythonDecoratedFunction(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
src := "@app.route(\"/\")\n" +
|
||||
"def home():\n" +
|
||||
" return \"hi\"\n"
|
||||
|
||||
out, _, err := SkeletonFile(context.Background(), r, "app.py", []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("SkeletonFile error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "@app.route(\"/\")") {
|
||||
t.Errorf("skeleton lost decorator, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "def home():") {
|
||||
t.Errorf("skeleton lost def signature, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "return \"hi\"") {
|
||||
t.Errorf("skeleton should elide the body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain (undecorated) Python function still renders its signature + body stub.
|
||||
func TestSkeletonPythonPlainFunction(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
src := "def add(a, b):\n return a + b\n"
|
||||
out, _, err := SkeletonFile(context.Background(), r, "m.py", []byte(src))
|
||||
if err != nil {
|
||||
t.Fatalf("SkeletonFile error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "def add(a, b):") {
|
||||
t.Errorf("skeleton lost signature, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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}
|
||||
}
|
||||
|
||||
// containsLicenseKeyword reports whether lowercased text looks like a license
|
||||
// header (contains "copyright", "license", or an SPDX identifier).
|
||||
func containsLicenseKeyword(lower string) bool {
|
||||
return strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier")
|
||||
}
|
||||
|
||||
// peelLicensePrefix splits off a leading run of lines that must be preserved
|
||||
// above a license header: a shebang (#!) on the first line and/or Go
|
||||
// build-constraint lines (//go:build, // +build). Returns the preserved prefix
|
||||
// (with terminators) and the remaining content. With no such lines, prefix is "".
|
||||
func peelLicensePrefix(content string) (prefix, rest string) {
|
||||
lines := strings.SplitAfter(content, "\n")
|
||||
n := 0
|
||||
for n < len(lines) {
|
||||
t := strings.TrimSpace(lines[n])
|
||||
isShebang := n == 0 && strings.HasPrefix(t, "#!")
|
||||
isBuild := strings.HasPrefix(t, "//go:build") || strings.HasPrefix(t, "// +build")
|
||||
if !isShebang && !isBuild {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return "", content
|
||||
}
|
||||
return strings.Join(lines[:n], ""), strings.Join(lines[n:], "")
|
||||
}
|
||||
|
||||
// stripLicense removes a leading license header comment. A comment qualifies if
|
||||
// it contains a license keyword (see containsLicenseKeyword). A leading shebang
|
||||
// or Go build-constraint block is preserved above the license rather than
|
||||
// consumed or blocking detection.
|
||||
func stripLicense(content string) (string, bool) {
|
||||
prefix, rest := peelLicensePrefix(content)
|
||||
next, removed := stripLeadingLicenseComment(rest)
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return prefix + next, true
|
||||
}
|
||||
|
||||
// stripLeadingLicenseComment removes a license header at the top of s, handling
|
||||
// C-style /* */ blocks, contiguous // line comments, and contiguous # comments.
|
||||
func stripLeadingLicenseComment(s string) (string, bool) {
|
||||
trimmed := strings.TrimLeft(s, " \t\n\r")
|
||||
|
||||
// C-style block comment at top
|
||||
if strings.HasPrefix(trimmed, "/*") {
|
||||
end := strings.Index(trimmed, "*/")
|
||||
if end >= 0 {
|
||||
candidate := trimmed[:end+2]
|
||||
if containsLicenseKeyword(strings.ToLower(candidate)) {
|
||||
rest := trimmed[end+2:]
|
||||
// Consume trailing newline(s)
|
||||
rest = strings.TrimLeft(rest, "\r\n")
|
||||
return rest, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "//" line-comment header (Go/C/TS). Only contiguous "//" lines belong to
|
||||
// the header; a non-comment line ends it and is preserved.
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
var commentLines, rest []string
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "//") {
|
||||
commentLines = append(commentLines, l)
|
||||
continue
|
||||
}
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
// Python/hash-style leading comment block. Only contiguous "#" lines belong to the
|
||||
// header; a blank line ends it and is preserved as a separator (rather than being
|
||||
// greedily swallowed and collapsed away).
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
var commentLines, rest []string
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "#") {
|
||||
commentLines = append(commentLines, l)
|
||||
continue
|
||||
}
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
return s, 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
|
||||
}
|
||||
// Single import spec in any form: import "path", import `path`,
|
||||
// import alias "path", import _ "path", import . "path".
|
||||
if strings.HasPrefix(trimLine, "import ") &&
|
||||
(strings.Contains(trimLine, `"`) || strings.Contains(trimLine, "`")) {
|
||||
removed = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = append(out, lines[i])
|
||||
i++
|
||||
}
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return strings.Join(out, "\n"), true
|
||||
}
|
||||
|
||||
// tsImportNeedsContinuation reports whether an import line (known to start an
|
||||
// import) continues onto following lines. A multi-line named import opens a
|
||||
// brace and only reaches its module specifier ("from '...'") on a later line.
|
||||
func tsImportNeedsContinuation(line string) bool {
|
||||
if strings.Contains(line, "from ") || strings.HasSuffix(line, ";") {
|
||||
return false
|
||||
}
|
||||
// Side-effect import (import '...' / import "...") is single-line.
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(line, "import"))
|
||||
if strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, `"`) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripTSImports removes TypeScript/JavaScript "import ... from ..." (including
|
||||
// multi-line named imports) and destructured require() lines.
|
||||
func stripTSImports(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 {
|
||||
removed = true
|
||||
// The module-specifier line ("from '...'") or a statement terminator ends it.
|
||||
if strings.Contains(trimLine, "from ") || strings.HasSuffix(trimLine, ";") {
|
||||
inMulti = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
isImport := strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "import{")
|
||||
isRequire := strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(")
|
||||
if isImport || isRequire {
|
||||
removed = true
|
||||
if isImport && tsImportNeedsContinuation(trimLine) {
|
||||
inMulti = 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)
|
||||
}
|
||||
|
||||
// trimTrailingLineWhitespace drops trailing spaces/tabs from out (back to, but not past,
|
||||
// the previous newline). Used when a standalone comment line is removed so its leading
|
||||
// indentation does not linger as a whitespace-only line.
|
||||
func trimTrailingLineWhitespace(out []byte) []byte {
|
||||
for len(out) > 0 && (out[len(out)-1] == ' ' || out[len(out)-1] == '\t') {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skipLineTail advances i over trailing spaces/tabs and a CR, then a single LF — i.e. the
|
||||
// remainder of a line after a standalone comment's closer, including its \n or \r\n
|
||||
// terminator. Returns the new index.
|
||||
func skipLineTail(content string, i int) int {
|
||||
for i < len(content) && (content[i] == ' ' || content[i] == '\t' || content[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i < len(content) && content[i] == '\n' {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// stripCStyleBlockComments removes /* ... */ comments. A comment that occupies a whole
|
||||
// line (only whitespace before it) is removed together with that line's indentation and
|
||||
// terminator; an inline comment (code precedes it) is removed in place, leaving the
|
||||
// surrounding line — and crucially its terminator — intact so lines are never merged.
|
||||
func stripCStyleBlockComments(content string) (string, bool) {
|
||||
removed := false
|
||||
out := make([]byte, 0, len(content))
|
||||
lineHasNonSpace := false
|
||||
i := 0
|
||||
for i < len(content) {
|
||||
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
|
||||
if end := strings.Index(content[i+2:], "*/"); end >= 0 {
|
||||
removed = true
|
||||
standalone := !lineHasNonSpace
|
||||
i = i + 2 + end + 2 // advance past closing */
|
||||
if standalone {
|
||||
out = trimTrailingLineWhitespace(out)
|
||||
i = skipLineTail(content, i)
|
||||
lineHasNonSpace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
c := content[i]
|
||||
switch c {
|
||||
case '\n':
|
||||
lineHasNonSpace = false
|
||||
case ' ', '\t', '\r':
|
||||
// whitespace: does not mark the line as having content
|
||||
default:
|
||||
lineHasNonSpace = true
|
||||
}
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// stripPythonDocstrings removes triple-quoted strings (""" and ”'). As with block
|
||||
// comments, a standalone docstring line is removed along with its indentation and
|
||||
// terminator, while an inline triple-quoted string leaves its line's terminator intact.
|
||||
func stripPythonDocstrings(content string) (string, bool) {
|
||||
removed := false
|
||||
out := make([]byte, 0, len(content))
|
||||
lineHasNonSpace := false
|
||||
i := 0
|
||||
for i < len(content) {
|
||||
if i+2 < len(content) {
|
||||
triple := content[i : i+3]
|
||||
if triple == `"""` || triple == `'''` {
|
||||
if end := strings.Index(content[i+3:], triple); end >= 0 {
|
||||
removed = true
|
||||
standalone := !lineHasNonSpace
|
||||
i = i + 3 + end + 3
|
||||
if standalone {
|
||||
out = trimTrailingLineWhitespace(out)
|
||||
i = skipLineTail(content, i)
|
||||
lineHasNonSpace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
c := content[i]
|
||||
switch c {
|
||||
case '\n':
|
||||
lineHasNonSpace = false
|
||||
case ' ', '\t', '\r':
|
||||
// whitespace: does not mark the line as having content
|
||||
default:
|
||||
lineHasNonSpace = true
|
||||
}
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// An inline block comment (code before it on the same line) must not cause the following
|
||||
// line to be merged onto it — the line's terminator must survive.
|
||||
func TestStripBlockCommentInlineNoLineMerge(t *testing.T) {
|
||||
got := StripContent("a := 1 /* note */\nb := 2\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "a := 1 \nb := 2\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("inline block comment must not merge lines.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A standalone block-comment line (only whitespace before it) is removed in full,
|
||||
// including its indentation and terminator — no stray blank/whitespace line left behind.
|
||||
func TestStripBlockCommentStandaloneRemovesLine(t *testing.T) {
|
||||
got := StripContent("x\n\t/* c */\ny\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "x\ny\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("standalone block comment line must be removed cleanly.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// On a CRLF file, removing a standalone block-comment line must consume the full \r\n
|
||||
// terminator rather than leaving a stray blank (bare-CR) line.
|
||||
func TestStripBlockCommentCRLFNoStrayBlank(t *testing.T) {
|
||||
got := StripContent("code\r\n/* c */\r\nmore\r\n", []StripFlag{StripBlockComments}, protocol.LangGo)
|
||||
want := "code\r\nmore\r\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("CRLF standalone block comment must not leave a stray blank line.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Stripping a hash-style license header must not greedily swallow the blank separator
|
||||
// line that follows it.
|
||||
func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
|
||||
got := StripContent("# Copyright 2024\n# License MIT\n\ncode\n", []StripFlag{StripLicense}, protocol.LangPython)
|
||||
want := "\ncode\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-line TS named import must be removed in full, not just its first line.
|
||||
func TestStripTSImportsMultiLine(t *testing.T) {
|
||||
src := "import {\n a,\n b,\n} from 'x';\nconst y = 1;\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1;\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("multi-line import must be fully removed.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A side-effect import without a semicolon must not consume following code.
|
||||
func TestStripTSImportsSideEffectNoRunaway(t *testing.T) {
|
||||
src := "import './setup'\nconst y = 1\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("side-effect import must not swallow code.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Blank/underscore/dot/aliased single Go imports must all be stripped.
|
||||
func TestStripGoImportsSingleSpecForms(t *testing.T) {
|
||||
src := "package main\n\nimport _ \"embed\"\nimport . \"math\"\nimport m \"strings\"\n\nfunc main() {}\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangGo)
|
||||
for _, frag := range []string{`"embed"`, `"math"`, `"strings"`} {
|
||||
if containsStr(got.Content, frag) {
|
||||
t.Fatalf("single import %s not stripped, got:\n%s", frag, got.Content)
|
||||
}
|
||||
}
|
||||
if !containsStr(got.Content, "func main") {
|
||||
t.Fatalf("code removed, got:\n%s", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A Go // line-comment license after build constraints: the license is removed
|
||||
// while the build constraint is preserved above it.
|
||||
func TestStripLicenseGoLineCommentAfterBuildTag(t *testing.T) {
|
||||
src := "//go:build linux\n\n// Copyright 2024 Acme\n// SPDX-License-Identifier: MIT\n\npackage main\n"
|
||||
got := StripContent(src, []StripFlag{StripLicense}, protocol.LangGo)
|
||||
if containsStr(got.Content, "Copyright") {
|
||||
t.Fatalf("line-comment license not stripped, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "//go:build linux") {
|
||||
t.Fatalf("build constraint must be preserved, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "package main") {
|
||||
t.Fatalf("code removed, got:\n%q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
+643
-24
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
@@ -25,12 +27,38 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
|
||||
return extractPythonSymbols(root, content, filename)
|
||||
case protocol.LangC, protocol.LangCpp:
|
||||
return extractCSymbols(root, content, filename)
|
||||
case protocol.LangElixir:
|
||||
return extractElixirSymbols(root, content, filename)
|
||||
case protocol.LangRust:
|
||||
return extractRustSymbols(root, content, filename)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// extractGoSymbols extracts symbols from Go code.
|
||||
// goBodyTypes are the node types whose descendants are function-local (not
|
||||
// package-level) declarations in Go.
|
||||
var goBodyTypes = map[string]bool{"block": true, "func_literal": true}
|
||||
|
||||
// jsBodyTypes are the node types whose descendants are function-local (not
|
||||
// module/class-member) declarations in JS/TS.
|
||||
var jsBodyTypes = map[string]bool{
|
||||
"statement_block": true, "function_declaration": true, "method_definition": true,
|
||||
"arrow_function": true, "function_expression": true, "generator_function_declaration": true,
|
||||
}
|
||||
|
||||
// isNestedInBody reports whether n has an ancestor in bodyTypes, i.e. it is a
|
||||
// function-local declaration rather than a top-level/module one.
|
||||
func isNestedInBody(n *sitter.Node, bodyTypes map[string]bool) bool {
|
||||
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||
if bodyTypes[p.Type()] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractGoSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
||||
var symbols []protocol.Symbol
|
||||
|
||||
@@ -45,6 +73,10 @@ func extractGoSymbols(root *sitter.Node, content []byte, filename string) []prot
|
||||
case "type_declaration":
|
||||
symbol = extractGoType(n, content, filename)
|
||||
case "const_declaration", "var_declaration":
|
||||
// Skip function-local var/const; only package-level ones are symbols.
|
||||
if isNestedInBody(n, goBodyTypes) {
|
||||
return true
|
||||
}
|
||||
syms := extractGoVarConst(n, content, filename)
|
||||
symbols = append(symbols, syms...)
|
||||
return true
|
||||
@@ -148,12 +180,20 @@ func extractGoVarConst(n *sitter.Node, content []byte, filename string) []protoc
|
||||
|
||||
WalkTree(n, func(node *sitter.Node) bool {
|
||||
if node.Type() == "const_spec" || node.Type() == "var_spec" {
|
||||
nameNode := node.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
// A spec may declare multiple names (e.g. var a, b = 1, 2);
|
||||
// ChildByFieldName returns only the first, so iterate all "name" fields.
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
if node.FieldNameForChild(i) != "name" {
|
||||
continue
|
||||
}
|
||||
nameNode := node.Child(i)
|
||||
if nameNode == nil {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: kind,
|
||||
Location: NodeLocation(node, filename),
|
||||
Location: NodeLocation(nameNode, filename),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -178,6 +218,11 @@ func extractJSSymbols(root *sitter.Node, content []byte, filename string) []prot
|
||||
case "method_definition":
|
||||
symbol = extractJSMethod(n, content, filename)
|
||||
case "lexical_declaration", "variable_declaration":
|
||||
// Skip function-local let/const/var; only module/top-level ones
|
||||
// (including `export const`) are symbols.
|
||||
if isNestedInBody(n, jsBodyTypes) {
|
||||
return true
|
||||
}
|
||||
syms := extractJSVariable(n, content, filename)
|
||||
symbols = append(symbols, syms...)
|
||||
return true
|
||||
@@ -382,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
|
||||
return symbols
|
||||
}
|
||||
|
||||
// cDeclaratorName descends the declarator field-chain (pointer/reference/array/
|
||||
// function declarators) to the innermost name node and returns its text. Unlike a
|
||||
// first-identifier scan, this returns the function/method NAME (not a parameter)
|
||||
// and handles C++ method names (field_identifier), out-of-line definitions
|
||||
// (qualified_identifier like User::foo), destructors and operators.
|
||||
func cDeclaratorName(declarator *sitter.Node, content []byte) string {
|
||||
node := declarator
|
||||
for node != nil {
|
||||
switch node.Type() {
|
||||
case "identifier", "field_identifier", "qualified_identifier",
|
||||
"destructor_name", "operator_name":
|
||||
return GetNodeText(node, content)
|
||||
}
|
||||
next := node.ChildByFieldName("declarator")
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
node = next
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enclosingCppClassName returns the name of the nearest class/struct ancestor of
|
||||
// n (for qualifying in-class method names), or "" if n is not inside one.
|
||||
func enclosingCppClassName(n *sitter.Node, content []byte) string {
|
||||
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||
if p.Type() == "class_specifier" || p.Type() == "struct_specifier" {
|
||||
if name := p.ChildByFieldName("name"); name != nil {
|
||||
return GetNodeText(name, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
declarator := n.ChildByFieldName("declarator")
|
||||
if declarator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the function name within the declarator
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// In-class definitions are methods; qualify the name with the class. Out-of-line
|
||||
// definitions already carry a qualified name (Class::method).
|
||||
kind := protocol.SymbolFunction
|
||||
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||
kind = protocol.SymbolMethod
|
||||
name = cls + "::" + name
|
||||
} else if strings.Contains(name, "::") {
|
||||
kind = protocol.SymbolMethod
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
@@ -441,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
|
||||
return nil
|
||||
}
|
||||
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
kind := protocol.SymbolFunction
|
||||
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||
kind = protocol.SymbolMethod
|
||||
name = cls + "::" + name
|
||||
} else if strings.Contains(name, "::") {
|
||||
kind = protocol.SymbolMethod
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
@@ -472,3 +553,541 @@ func hasFunctionDeclarator(n *sitter.Node) bool {
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// extractElixirSymbols extracts symbols from Elixir code.
|
||||
// Elixir uses `defmodule` for modules, `def`/`defp` for functions, and `defmacro`/`defmacrop` for macros.
|
||||
func extractElixirSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
||||
var symbols []protocol.Symbol
|
||||
|
||||
WalkTree(root, func(n *sitter.Node) bool {
|
||||
var symbol *protocol.Symbol
|
||||
|
||||
switch n.Type() {
|
||||
case "call":
|
||||
symbol = extractElixirCall(n, content, filename)
|
||||
}
|
||||
|
||||
if symbol != nil {
|
||||
if doc := ExtractDocComment(n, content, protocol.LangElixir); doc != nil {
|
||||
symbol.Doc = FormatDocComment(doc)
|
||||
}
|
||||
symbols = append(symbols, *symbol)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// extractElixirCall extracts symbols from Elixir call nodes (def, defp, defmodule, defmacro, etc.).
|
||||
func extractElixirCall(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// Get the function being called (first child is usually the target)
|
||||
if n.NamedChildCount() < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
target := n.NamedChild(0)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetText := GetNodeText(target, content)
|
||||
|
||||
switch targetText {
|
||||
case "defmodule":
|
||||
return extractElixirModule(n, content, filename)
|
||||
case "def", "defp":
|
||||
return extractElixirFunction(n, content, filename, targetText == "defp")
|
||||
case "defmacro", "defmacrop":
|
||||
return extractElixirMacro(n, content, filename)
|
||||
case "defstruct":
|
||||
return extractElixirStruct(n, content, filename)
|
||||
case "defprotocol":
|
||||
return extractElixirProtocol(n, content, filename)
|
||||
case "defimpl":
|
||||
return extractElixirImpl(n, content, filename)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractElixirModule extracts a module definition.
|
||||
func extractElixirModule(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defmodule ModuleName do ... end
|
||||
// The module name is in the arguments
|
||||
args := n.ChildByFieldName("arguments")
|
||||
if args == nil {
|
||||
// Try finding it as the second named child
|
||||
if n.NamedChildCount() >= 2 {
|
||||
args = n.NamedChild(1)
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the alias (module name) in the arguments
|
||||
var moduleName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
moduleName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if moduleName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: moduleName,
|
||||
Kind: protocol.SymbolModule,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirFunction extracts a function definition.
|
||||
func extractElixirFunction(n *sitter.Node, content []byte, filename string, isPrivate bool) *protocol.Symbol {
|
||||
// def function_name(args) do ... end
|
||||
// The function name and args are in the arguments of the call
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Second child contains the function definition
|
||||
funcDef := n.NamedChild(1)
|
||||
if funcDef == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var funcName string
|
||||
|
||||
// The function definition can be:
|
||||
// 1. A call node (function with args): func_name(arg1, arg2)
|
||||
// 2. An identifier (function without args): func_name
|
||||
switch funcDef.Type() {
|
||||
case "call":
|
||||
// Get the function name from the call target
|
||||
if funcDef.NamedChildCount() >= 1 {
|
||||
nameNode := funcDef.NamedChild(0)
|
||||
if nameNode != nil {
|
||||
funcName = GetNodeText(nameNode, content)
|
||||
}
|
||||
}
|
||||
case "identifier":
|
||||
funcName = GetNodeText(funcDef, content)
|
||||
case "binary_operator":
|
||||
// Guard clause: def func_name(args) when guard do ... end
|
||||
// The left side contains the actual function call
|
||||
WalkTree(funcDef, func(node *sitter.Node) bool {
|
||||
if node.Type() == "call" && node.NamedChildCount() >= 1 {
|
||||
nameNode := node.NamedChild(0)
|
||||
if nameNode != nil && nameNode.Type() == "identifier" {
|
||||
funcName = GetNodeText(nameNode, content)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if node.Type() == "identifier" && funcName == "" {
|
||||
funcName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if funcName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
kind := protocol.SymbolFunction
|
||||
if isPrivate {
|
||||
funcName = funcName + " (private)"
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: funcName,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirMacro extracts a macro definition.
|
||||
func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// Similar to function extraction
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
funcDef := n.NamedChild(1)
|
||||
if funcDef == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var macroName string
|
||||
|
||||
switch funcDef.Type() {
|
||||
case "call":
|
||||
if funcDef.NamedChildCount() >= 1 {
|
||||
nameNode := funcDef.NamedChild(0)
|
||||
if nameNode != nil {
|
||||
macroName = GetNodeText(nameNode, content)
|
||||
}
|
||||
}
|
||||
case "identifier":
|
||||
macroName = GetNodeText(funcDef, content)
|
||||
}
|
||||
|
||||
if macroName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: macroName + " (macro)",
|
||||
Kind: protocol.SymbolFunction,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirStruct extracts a struct definition. A defstruct has no name of
|
||||
// its own — it defines the struct of its enclosing module — so name it after
|
||||
// that module (%ModuleName{}) rather than the useless literal "defstruct".
|
||||
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
name := "defstruct"
|
||||
if mod := enclosingElixirModuleName(n, content); mod != "" {
|
||||
name = "%" + mod + "{}"
|
||||
}
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolStruct,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// enclosingElixirModuleName walks ancestors of n to the nearest defmodule call
|
||||
// and returns its module alias (e.g. "MyApp.User"), or "" if none is found.
|
||||
func enclosingElixirModuleName(n *sitter.Node, content []byte) string {
|
||||
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||
if p.Type() != "call" || p.NamedChildCount() < 1 {
|
||||
continue
|
||||
}
|
||||
target := p.NamedChild(0)
|
||||
if target == nil || GetNodeText(target, content) != "defmodule" {
|
||||
continue
|
||||
}
|
||||
args := p.ChildByFieldName("arguments")
|
||||
if args == nil && p.NamedChildCount() >= 2 {
|
||||
args = p.NamedChild(1)
|
||||
}
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
var moduleName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
moduleName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return moduleName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractElixirProtocol extracts a protocol definition.
|
||||
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defprotocol ProtocolName do ... end
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := n.NamedChild(1)
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var protocolName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
protocolName = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if protocolName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: protocolName,
|
||||
Kind: protocol.SymbolInterface,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractRustSymbols extracts symbols from Rust code.
|
||||
func extractRustSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
|
||||
var symbols []protocol.Symbol
|
||||
|
||||
WalkTree(root, func(n *sitter.Node) bool {
|
||||
var symbol *protocol.Symbol
|
||||
|
||||
switch n.Type() {
|
||||
case "function_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolFunction,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "struct_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolStruct,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "enum_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolEnum,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "trait_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolTrait,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "impl_item":
|
||||
symbol = extractRustImpl(n, content, filename)
|
||||
case "type_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolType,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "const_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolConstant,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "static_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolVariable,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "macro_definition":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content) + " (macro)",
|
||||
Kind: protocol.SymbolFunction,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
case "mod_item":
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode != nil {
|
||||
symbol = &protocol.Symbol{
|
||||
Name: GetNodeText(nameNode, content),
|
||||
Kind: protocol.SymbolModule,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if symbol != nil {
|
||||
if doc := ExtractDocComment(n, content, protocol.LangRust); doc != nil {
|
||||
symbol.Doc = FormatDocComment(doc)
|
||||
}
|
||||
symbols = append(symbols, *symbol)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// extractRustImpl extracts an impl block symbol from Rust code.
|
||||
func extractRustImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
typeNode := n.ChildByFieldName("type")
|
||||
traitNode := n.ChildByFieldName("trait")
|
||||
|
||||
var name string
|
||||
if traitNode != nil && typeNode != nil {
|
||||
name = "impl " + GetNodeText(traitNode, content) + " for " + GetNodeText(typeNode, content)
|
||||
} else if typeNode != nil {
|
||||
name = "impl " + GetNodeText(typeNode, content)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolType,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// extractElixirImpl extracts a protocol implementation.
|
||||
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
// defimpl Protocol, for: Type do ... end
|
||||
if n.NamedChildCount() < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := n.NamedChild(1)
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var implName string
|
||||
WalkTree(args, func(node *sitter.Node) bool {
|
||||
if node.Type() == "alias" {
|
||||
if implName == "" {
|
||||
implName = GetNodeText(node, content)
|
||||
} else {
|
||||
implName = implName + " for " + GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if implName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: implName,
|
||||
Kind: protocol.SymbolClass,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// kindMatchesNode returns true if the given SymbolKind matches the node type.
|
||||
// An empty kind matches all symbol-bearing nodes.
|
||||
func kindMatchesNode(kind protocol.SymbolKind, nodeType string) bool {
|
||||
if kind == "" {
|
||||
return true
|
||||
}
|
||||
switch kind {
|
||||
case protocol.SymbolFunction:
|
||||
return nodeType == "function_declaration" || nodeType == "function_definition" || nodeType == "function_item"
|
||||
case protocol.SymbolMethod:
|
||||
return nodeType == "method_declaration" || nodeType == "method_definition"
|
||||
case protocol.SymbolClass:
|
||||
return nodeType == "class_declaration" || nodeType == "class_definition"
|
||||
case protocol.SymbolStruct:
|
||||
return nodeType == "struct_item" || nodeType == "type_declaration"
|
||||
case protocol.SymbolInterface:
|
||||
return nodeType == "interface_declaration"
|
||||
case protocol.SymbolType:
|
||||
return nodeType == "type_declaration" || nodeType == "type_alias_declaration" || nodeType == "type_item"
|
||||
case protocol.SymbolEnum:
|
||||
return nodeType == "enum_item"
|
||||
case protocol.SymbolTrait:
|
||||
return nodeType == "trait_item"
|
||||
case protocol.SymbolConstant:
|
||||
return nodeType == "const_item"
|
||||
case protocol.SymbolModule:
|
||||
return nodeType == "mod_item"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SymbolCandidate is one symbol matching a name (and optional kind) lookup,
|
||||
// with its declaration node type and 1-indexed inclusive line range. NameLine
|
||||
// and NameColumn point at the name identifier itself (1-indexed), which is the
|
||||
// position LSP queries (hover/definition/references) expect.
|
||||
type SymbolCandidate struct {
|
||||
NodeType string
|
||||
StartLine int
|
||||
EndLine int
|
||||
NameLine int
|
||||
NameColumn int
|
||||
}
|
||||
|
||||
// symbolDeclNodeTypes is the set of declaration node types FindSymbolCandidates
|
||||
// treats as nameable symbols across supported languages.
|
||||
var symbolDeclNodeTypes = map[string]bool{
|
||||
"function_declaration": true, "method_declaration": true, "type_declaration": true,
|
||||
"function_definition": true, "class_definition": true, "class_declaration": true,
|
||||
"interface_declaration": true, "type_alias_declaration": true,
|
||||
"function_item": true, "struct_item": true, "enum_item": true, "trait_item": true,
|
||||
"type_item": true, "const_item": true, "mod_item": true,
|
||||
}
|
||||
|
||||
// FindSymbolCandidates returns every declaration whose name equals symbolName and
|
||||
// whose kind matches symbolKind (empty matches any). Multiple results indicate an
|
||||
// ambiguous lookup (e.g. two methods named String on different receivers).
|
||||
func FindSymbolCandidates(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) []SymbolCandidate {
|
||||
if tree == nil || symbolName == "" {
|
||||
return nil
|
||||
}
|
||||
var candidates []SymbolCandidate
|
||||
WalkTree(tree.RootNode(), func(n *sitter.Node) bool {
|
||||
nameNode := n.ChildByFieldName("name")
|
||||
if nameNode == nil {
|
||||
return true
|
||||
}
|
||||
if GetNodeText(nameNode, content) != symbolName {
|
||||
return true
|
||||
}
|
||||
if !symbolDeclNodeTypes[n.Type()] {
|
||||
return true
|
||||
}
|
||||
if !kindMatchesNode(symbolKind, n.Type()) {
|
||||
return true // kind mismatch, keep searching
|
||||
}
|
||||
r := NodeRange(n, filename)
|
||||
nameLoc := NodeLocation(nameNode, filename)
|
||||
candidates = append(candidates, SymbolCandidate{
|
||||
NodeType: n.Type(),
|
||||
StartLine: r.Start.Line,
|
||||
EndLine: r.End.Line,
|
||||
NameLine: nameLoc.Line,
|
||||
NameColumn: nameLoc.Column,
|
||||
})
|
||||
return true
|
||||
})
|
||||
return candidates
|
||||
}
|
||||
|
||||
// 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. On ambiguity it returns the first match;
|
||||
// callers that need to detect ambiguity should use FindSymbolCandidates.
|
||||
func FindSymbolRange(tree *sitter.Tree, content []byte, filename, symbolName string, symbolKind protocol.SymbolKind) (startLine, endLine int, found bool) {
|
||||
candidates := FindSymbolCandidates(tree, content, filename, symbolName, symbolKind)
|
||||
if len(candidates) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return candidates[0].StartLine, candidates[0].EndLine, true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,64 @@ import (
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestFindSymbolCandidates(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
// Two methods named String on different receivers, plus a unique func.
|
||||
content := `package main
|
||||
|
||||
type A struct{}
|
||||
|
||||
func (a A) String() string { return "a" }
|
||||
|
||||
type B struct{}
|
||||
|
||||
func (b B) String() string { return "b" }
|
||||
|
||||
func Unique() {}
|
||||
`
|
||||
ctx := context.Background()
|
||||
result, err := r.Parse(ctx, "test.go", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("ambiguous name returns all candidates", func(t *testing.T) {
|
||||
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "String", "")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates for String, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].StartLine >= got[1].StartLine {
|
||||
t.Errorf("candidates not in source order: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unique name returns one candidate", func(t *testing.T) {
|
||||
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Unique", "")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d candidates for Unique, want 1: %+v", len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing name returns none", func(t *testing.T) {
|
||||
got := FindSymbolCandidates(result.Tree, []byte(content), "test.go", "Missing", "")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("got %d candidates for Missing, want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FindSymbolRange returns first match", func(t *testing.T) {
|
||||
start, end, found := FindSymbolRange(result.Tree, []byte(content), "test.go", "String", "")
|
||||
if !found {
|
||||
t.Fatal("expected String to be found")
|
||||
}
|
||||
if start <= 0 || end < start {
|
||||
t.Errorf("invalid range: start=%d end=%d", start, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractGoSymbols(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
@@ -65,6 +123,149 @@ var globalVar = "test"
|
||||
}
|
||||
}
|
||||
|
||||
// Grouped var/const specs declare multiple names on one spec (var a, b = 1, 2);
|
||||
// every name must surface as its own symbol, not just the first.
|
||||
// C++ in-class methods must be extracted as methods qualified by their class
|
||||
// (Class::method), not misnamed after a parameter or dropped entirely.
|
||||
func TestExtractCppMethods(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
content := "class User {\npublic:\n void setName(const char* n) { name_ = n; }\n int id() const { return id_; }\nprivate:\n const char* name_;\n int id_;\n};\n\nvoid freeFunc() {}\n"
|
||||
result, err := r.Parse(context.Background(), "u.cpp", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
syms := ExtractSymbols(result.Tree, []byte(content), protocol.LangCpp, "u.cpp")
|
||||
|
||||
kindByName := make(map[string]protocol.SymbolKind, len(syms))
|
||||
for _, s := range syms {
|
||||
kindByName[s.Name] = s.Kind
|
||||
}
|
||||
if kindByName["User::setName"] != protocol.SymbolMethod {
|
||||
t.Errorf("setName: want method User::setName, got %v", kindByName)
|
||||
}
|
||||
if kindByName["User::id"] != protocol.SymbolMethod {
|
||||
t.Errorf("id: want method User::id, got %v", kindByName)
|
||||
}
|
||||
if kindByName["freeFunc"] != protocol.SymbolFunction {
|
||||
t.Errorf("freeFunc: want function, got %v", kindByName)
|
||||
}
|
||||
if _, leaked := kindByName["n"]; leaked {
|
||||
t.Errorf("parameter 'n' leaked as a symbol: %v", kindByName)
|
||||
}
|
||||
}
|
||||
|
||||
// Function-local var/const declarations must NOT appear as top-level symbols
|
||||
// (they flood the list and aren't package/module symbols).
|
||||
func TestExtractSymbolsExcludesLocals(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("go", func(t *testing.T) {
|
||||
content := "package main\n\nvar TopLevel = 1\n\nfunc f() {\n\tvar localVar = 2\n\tconst localConst = 3\n\t_ = localVar\n\t_ = localConst\n}\n"
|
||||
result, err := r.Parse(ctx, "m.go", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "m.go"))
|
||||
if !names["TopLevel"] {
|
||||
t.Errorf("expected TopLevel symbol, got %v", names)
|
||||
}
|
||||
if names["localVar"] || names["localConst"] {
|
||||
t.Errorf("function-local var/const leaked into symbols: %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("js exported and module consts kept, locals dropped", func(t *testing.T) {
|
||||
content := "export const Exported = 1;\nconst ModuleConst = 2;\nfunction f() {\n const localOne = 3;\n return localOne;\n}\n"
|
||||
result, err := r.Parse(ctx, "m.js", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
names := symbolNameSet(ExtractSymbols(result.Tree, []byte(content), protocol.LangJavaScript, "m.js"))
|
||||
if !names["Exported"] || !names["ModuleConst"] {
|
||||
t.Errorf("expected Exported and ModuleConst symbols, got %v", names)
|
||||
}
|
||||
if names["localOne"] {
|
||||
t.Errorf("function-local const leaked into symbols: %v", names)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func symbolNameSet(syms []protocol.Symbol) map[string]bool {
|
||||
m := make(map[string]bool, len(syms))
|
||||
for _, s := range syms {
|
||||
m[s.Name] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// An Elixir defstruct must be named after its enclosing module (%Mod{}),
|
||||
// not the useless literal "defstruct".
|
||||
func TestExtractElixirStructNamedByModule(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
content := "defmodule MyApp.User do\n defstruct [:name, :email]\nend\n"
|
||||
result, err := r.Parse(context.Background(), "user.ex", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "user.ex")
|
||||
var structName string
|
||||
found := false
|
||||
for _, s := range symbols {
|
||||
if s.Kind == protocol.SymbolStruct {
|
||||
structName = s.Name
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no struct symbol extracted, got: %+v", symbols)
|
||||
}
|
||||
if structName != "%MyApp.User{}" {
|
||||
t.Errorf("struct name = %q, want %q", structName, "%MyApp.User{}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGoVarConstMultiName(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
content := `package main
|
||||
|
||||
var a, b = 1, 2
|
||||
|
||||
const (
|
||||
C, D = 3, 4
|
||||
)
|
||||
`
|
||||
ctx := context.Background()
|
||||
result, err := r.Parse(ctx, "test.go", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangGo, "test.go")
|
||||
want := map[string]protocol.SymbolKind{
|
||||
"a": protocol.SymbolVariable,
|
||||
"b": protocol.SymbolVariable,
|
||||
"C": protocol.SymbolConstant,
|
||||
"D": protocol.SymbolConstant,
|
||||
}
|
||||
got := make(map[string]protocol.SymbolKind)
|
||||
for _, sym := range symbols {
|
||||
got[sym.Name] = sym.Kind
|
||||
}
|
||||
for name, kind := range want {
|
||||
if got[name] != kind {
|
||||
t.Errorf("symbol %q: got kind %q, want %q (symbols: %+v)", name, got[name], kind, symbols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSSymbols(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
@@ -224,3 +425,96 @@ int main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractElixirSymbols(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
|
||||
content := `defmodule MyApp.User do
|
||||
@moduledoc """
|
||||
User module for the application.
|
||||
"""
|
||||
|
||||
defstruct [:name, :email]
|
||||
|
||||
@doc """
|
||||
Creates a new user.
|
||||
"""
|
||||
def new(name, email) do
|
||||
%__MODULE__{name: name, email: email}
|
||||
end
|
||||
|
||||
defp validate(user) do
|
||||
# Private validation function
|
||||
user
|
||||
end
|
||||
|
||||
defmacro is_user(term) do
|
||||
quote do
|
||||
is_struct(unquote(term), __MODULE__)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defprotocol Greeting do
|
||||
@doc "Greet the entity"
|
||||
def greet(entity)
|
||||
end
|
||||
|
||||
defimpl Greeting, for: MyApp.User do
|
||||
def greet(user) do
|
||||
"Hello, #{user.name}!"
|
||||
end
|
||||
end
|
||||
`
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := r.Parse(ctx, "test.ex", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "test.ex")
|
||||
|
||||
// Check that we found some symbols
|
||||
if len(symbols) == 0 {
|
||||
t.Fatal("expected to find some symbols")
|
||||
}
|
||||
|
||||
// Look for specific expected symbols - we focus on top-level constructs
|
||||
// that the current implementation can reliably extract
|
||||
expectedSymbols := map[string]protocol.SymbolKind{
|
||||
"MyApp.User": protocol.SymbolModule,
|
||||
"Greeting": protocol.SymbolInterface,
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
for _, sym := range symbols {
|
||||
for name, expectedKind := range expectedSymbols {
|
||||
if sym.Name == name {
|
||||
found[name] = true
|
||||
if sym.Kind != expectedKind {
|
||||
t.Errorf("symbol %s: expected kind %s, got %s", sym.Name, expectedKind, sym.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name := range expectedSymbols {
|
||||
if !found[name] {
|
||||
t.Errorf("expected to find symbol %s, found symbols: %v", name, symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we found the defstruct
|
||||
foundStruct := false
|
||||
for _, sym := range symbols {
|
||||
if sym.Kind == protocol.SymbolStruct {
|
||||
foundStruct = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundStruct {
|
||||
t.Error("expected to find a struct symbol")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-149
@@ -4,70 +4,45 @@ package parser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
|
||||
type YAMLNode struct {
|
||||
*yaml.Node
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// JSONNode represents a JSON AST node
|
||||
type JSONNode struct {
|
||||
Value any
|
||||
Type string
|
||||
Children []*JSONNode
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
|
||||
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
|
||||
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > MaxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, errors.NewParseError("yaml", filename, err)
|
||||
}
|
||||
|
||||
// Extract syntax errors from YAML parse
|
||||
syntaxErrors := extractYAMLErrors()
|
||||
|
||||
// Create a pseudo tree-sitter tree for compatibility
|
||||
// We'll use nil for the tree since YAML doesn't use tree-sitter
|
||||
return &ParseResult{
|
||||
Tree: nil, // YAML uses yaml.Node instead
|
||||
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
|
||||
Language: protocol.LangYAML,
|
||||
Errors: syntaxErrors,
|
||||
Errors: extractYAMLErrors(),
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
|
||||
// ParseJSON parses JSON content and returns a tree-sitter-compatible result.
|
||||
// JSON does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||
// Check file size
|
||||
if len(content) > MaxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), MaxFileSize)
|
||||
if len(content) > maxFileSize {
|
||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||
}
|
||||
|
||||
// Parse JSON to validate syntax
|
||||
var jsonData any
|
||||
if err := json.Unmarshal(content, &jsonData); err != nil {
|
||||
return nil, errors.NewParseError("json", filename, err)
|
||||
}
|
||||
|
||||
// JSON parsing succeeded, no syntax errors
|
||||
return &ParseResult{
|
||||
Tree: nil, // JSON uses native Go structures
|
||||
Language: protocol.LangJSON,
|
||||
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractYAMLErrors extracts errors from YAML nodes
|
||||
// extractYAMLErrors returns YAML syntax errors. yaml.Unmarshal already reports
|
||||
// parse errors, so reaching here means the document was syntactically valid.
|
||||
func extractYAMLErrors() []SyntaxError {
|
||||
// YAML parser already validates during unmarshal
|
||||
// If we got here, there are no syntax errors
|
||||
// However, we could add semantic validation here in the future
|
||||
return []SyntaxError{}
|
||||
}
|
||||
|
||||
// WalkYAML walks a YAML AST and calls fn for each node
|
||||
func WalkYAML(node *yaml.Node, fn func(*yaml.Node) bool) {
|
||||
if node == nil || !fn(node) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, child := range node.Content {
|
||||
WalkYAML(child, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeText returns the text representation of a YAML node
|
||||
func GetYAMLNodeText(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
if len(node.Content) > 0 {
|
||||
return GetYAMLNodeText(node.Content[0])
|
||||
}
|
||||
return ""
|
||||
case yaml.MappingNode:
|
||||
return node.Value
|
||||
case yaml.SequenceNode:
|
||||
return node.Value
|
||||
case yaml.ScalarNode:
|
||||
return node.Value
|
||||
case yaml.AliasNode:
|
||||
return node.Value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAMLNodeLocation returns the location of a YAML node
|
||||
func GetYAMLNodeLocation(node *yaml.Node) protocol.Location {
|
||||
if node == nil {
|
||||
return protocol.Location{Line: 1, Column: 1}
|
||||
}
|
||||
|
||||
return protocol.Location{
|
||||
Line: node.Line,
|
||||
Column: node.Column,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryYAML performs a simple query on YAML content
|
||||
// Example: "$.metadata.name" to find the name field in metadata
|
||||
func QueryYAML(content []byte, query string) ([]*yaml.Node, error) {
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||
}
|
||||
|
||||
// Simple path-based query implementation
|
||||
// This is a basic implementation - can be extended with more sophisticated queries
|
||||
var results []*yaml.Node
|
||||
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
if node.Value == query || node.Tag == query {
|
||||
results = append(results, node)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// QueryJSON performs a simple query on JSON content
|
||||
func QueryJSON(content []byte, query string) ([]any, error) {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Basic implementation - can be extended with JSONPath support
|
||||
var results []any
|
||||
|
||||
// For now, just validate that it's valid JSON
|
||||
results = append(results, data)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ValidateYAML validates YAML content without parsing to full AST
|
||||
func ValidateYAML(content []byte) error {
|
||||
var node yaml.Node
|
||||
if err := yaml.Unmarshal(content, &node); err != nil {
|
||||
return fmt.Errorf("YAML validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSON validates JSON content
|
||||
func ValidateJSON(content []byte) error {
|
||||
var data any
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
return fmt.Errorf("JSON validation failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToSitterTree is a placeholder that returns nil for YAML/JSON
|
||||
// These formats don't use tree-sitter, but we keep this for interface compatibility
|
||||
func (yn *YAMLNode) ToSitterTree() *sitter.Tree {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -202,82 +200,3 @@ version: 1.0.0`)
|
||||
t.Errorf("expected YAML language for .yml extension, got %s", ymlResult.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkYAML(t *testing.T) {
|
||||
content := []byte(`metadata:
|
||||
name: test
|
||||
labels:
|
||||
app: myapp
|
||||
env: prod`)
|
||||
|
||||
var root yaml.Node
|
||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||
t.Fatalf("failed to parse YAML: %v", err)
|
||||
}
|
||||
|
||||
nodeCount := 0
|
||||
WalkYAML(&root, func(node *yaml.Node) bool {
|
||||
nodeCount++
|
||||
return true
|
||||
})
|
||||
|
||||
if nodeCount == 0 {
|
||||
t.Error("expected to visit nodes, but count is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid YAML",
|
||||
content: []byte("name: test\nvalue: 100"),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
content: []byte("name: test\n bad:\n[unclosed"),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateYAML(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateYAML() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100}`),
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
content: []byte(`{"name": "test", "value": 100`),
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJSON(tt.content)
|
||||
if (err != nil) != tt.shouldError {
|
||||
t.Errorf("ValidateJSON() error = %v, shouldError = %v", err, tt.shouldError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place
|
||||
// of the bare "[remaining: N]" hint when results are truncated.
|
||||
func TestFormatResultsWithCursor(t *testing.T) {
|
||||
results := makeResults(5)
|
||||
cursor := "[cursor: abc123, remaining: 3]"
|
||||
|
||||
out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor)
|
||||
if !strings.Contains(out, cursor) {
|
||||
t.Errorf("expected cursor footer %q, got:\n%s", cursor, out)
|
||||
}
|
||||
if strings.Contains(out, "[remaining: 3]\n") {
|
||||
t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions).
|
||||
fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "")
|
||||
if !strings.Contains(fallback, "[remaining: 3]") {
|
||||
t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback)
|
||||
}
|
||||
|
||||
// No truncation → no footer at all.
|
||||
none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor)
|
||||
if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") {
|
||||
t.Errorf("no truncation should emit no footer, got:\n%s", none)
|
||||
}
|
||||
}
|
||||
|
||||
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…"},
|
||||
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
|
||||
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
|
||||
{"ααα", 4, "α…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := firstLineOf(c.input, c.maxLen)
|
||||
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:])
|
||||
}(), ""), "")
|
||||
}
|
||||
+131
-74
@@ -6,37 +6,13 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
|
||||
// Global regex cache for compiled patterns (thread-safe)
|
||||
var regexCache sync.Map // string -> *regexp.Regexp
|
||||
|
||||
// compileRegex compiles a regex pattern with caching for performance.
|
||||
// Cached patterns avoid repeated compilation overhead (10-50x speedup).
|
||||
// Thread-safe: uses LoadOrStore to prevent race conditions.
|
||||
func compileRegex(pattern string) (*regexp.Regexp, error) {
|
||||
// Check cache first
|
||||
if cached, ok := regexCache.Load(pattern); ok {
|
||||
return cached.(*regexp.Regexp), nil
|
||||
}
|
||||
|
||||
// Compile regex
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to store - if another goroutine already stored it, use theirs
|
||||
// This prevents race conditions where multiple goroutines compile the same pattern
|
||||
actual, _ := regexCache.LoadOrStore(pattern, re)
|
||||
return actual.(*regexp.Regexp), nil
|
||||
}
|
||||
|
||||
// ASTQuery defines a query for matching AST patterns.
|
||||
type ASTQuery struct {
|
||||
Pattern string `json:"pattern"` // code pattern with $VAR placeholders
|
||||
@@ -46,13 +22,11 @@ type ASTQuery struct {
|
||||
|
||||
// QueryFilters provide additional filtering criteria.
|
||||
type QueryFilters struct {
|
||||
HasChild *ASTQuery `json:"has_child,omitempty"`
|
||||
HasParent *ASTQuery `json:"has_parent,omitempty"`
|
||||
NameMatches string `json:"name_matches,omitempty"`
|
||||
NameExact string `json:"name_exact,omitempty"`
|
||||
InFile string `json:"in_file,omitempty"`
|
||||
NotInFile string `json:"not_in_file,omitempty"`
|
||||
KindIn []string `json:"kind_in,omitempty"`
|
||||
NameMatches string `json:"name_matches,omitempty"`
|
||||
NameExact string `json:"name_exact,omitempty"`
|
||||
InFile string `json:"in_file,omitempty"`
|
||||
NotInFile string `json:"not_in_file,omitempty"`
|
||||
KindIn []string `json:"kind_in,omitempty"`
|
||||
}
|
||||
|
||||
// MatchResult represents a single match from a query.
|
||||
@@ -283,7 +257,7 @@ func matchPatternHeuristic(node *sitter.Node, pattern *ParsedPattern, content []
|
||||
}
|
||||
|
||||
// Match struct patterns (Go, C, C++)
|
||||
if strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ") && strings.Contains(patternLower, "struct") {
|
||||
if (strings.Contains(patternLower, "struct ") || strings.Contains(patternLower, "type ")) && strings.Contains(patternLower, "struct") {
|
||||
if nodeType != "type_declaration" && nodeType != "struct_specifier" {
|
||||
return false
|
||||
}
|
||||
@@ -438,7 +412,7 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
|
||||
return false
|
||||
}
|
||||
name := parser.GetNodeText(nameNode, content)
|
||||
re, err := compileRegex(filters.NameMatches)
|
||||
re, err := util.CompileRegex(filters.NameMatches)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -477,62 +451,145 @@ func passesFilters(node *sitter.Node, filters QueryFilters, content []byte) bool
|
||||
return true
|
||||
}
|
||||
|
||||
// FormatResults formats match results for display.
|
||||
// FormatResults formats match results for display (backward-compat wrapper, verbose mode).
|
||||
func FormatResults(results []MatchResult, maxResults int) string {
|
||||
return FormatResultsWithOptions(results, maxResults, "verbose", 0)
|
||||
}
|
||||
|
||||
// FormatResultsWithOptions formats match results with configurable output format.
|
||||
// format: "verbose" (default) | "compact" | "location"
|
||||
// offset: skip this many results before rendering (used for cursor pagination).
|
||||
// verbose: opt-in variadic — pass true to restore "Found N match(es):" preamble (v1 behaviour).
|
||||
// When results are truncated it emits a bare "[remaining: N]" hint; callers that
|
||||
// have a pagination cursor should use FormatResultsWithCursor to emit the full line.
|
||||
func FormatResultsWithOptions(results []MatchResult, maxResults int, format string, offset int, verbose ...bool) string {
|
||||
return formatResults(results, maxResults, format, offset, len(verbose) > 0 && verbose[0], "")
|
||||
}
|
||||
|
||||
// FormatResultsWithCursor is like FormatResultsWithOptions but, when results are
|
||||
// truncated, appends cursorLine verbatim instead of the bare "[remaining: N]"
|
||||
// hint. This lets the caller pass a fully-formed "[cursor: ..., remaining: N]"
|
||||
// footer without string-replacing a placeholder. If cursorLine is empty it
|
||||
// behaves like FormatResultsWithOptions.
|
||||
func FormatResultsWithCursor(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
|
||||
return formatResults(results, maxResults, format, offset, verbose, cursorLine)
|
||||
}
|
||||
|
||||
// formatResults is the shared implementation. cursorLine, when non-empty, is
|
||||
// emitted in place of the "[remaining: N]" hint on truncation.
|
||||
func formatResults(results []MatchResult, maxResults int, format string, offset int, verbose bool, cursorLine string) string {
|
||||
if len(results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d match(es):\n\n", len(results)))
|
||||
|
||||
displayCount := len(results)
|
||||
truncated := false
|
||||
if maxResults > 0 && displayCount > maxResults {
|
||||
displayCount = maxResults
|
||||
truncated = true
|
||||
// Apply offset (pagination skip).
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
return "No matches found."
|
||||
}
|
||||
results = results[offset:]
|
||||
}
|
||||
|
||||
for i := 0; i < displayCount; i++ {
|
||||
r := results[i]
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s:%d** (%s)\n", r.File, r.Location.Line, nodeType))
|
||||
// Determine how many to render and whether more remain.
|
||||
renderCount := len(results)
|
||||
remaining := 0
|
||||
if maxResults > 0 && renderCount > maxResults {
|
||||
remaining = renderCount - maxResults
|
||||
renderCount = maxResults
|
||||
}
|
||||
|
||||
// Truncate very long text
|
||||
text := r.Text
|
||||
if len(text) > 500 {
|
||||
text = text[:500] + "..."
|
||||
}
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n```\n")
|
||||
var sb strings.Builder
|
||||
|
||||
// Show captures
|
||||
if len(r.Captures) > 0 {
|
||||
sb.WriteString("Captures: ")
|
||||
first := true
|
||||
for name, cap := range r.Captures {
|
||||
if !first {
|
||||
sb.WriteString(", ")
|
||||
// Emit preamble only when verbose=true is explicitly passed (opt-in, default off).
|
||||
if verbose {
|
||||
fmt.Fprintf(&sb, "Found %d match(es):\n", renderCount)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "compact":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
firstLine := firstLineOf(r.Text, 80)
|
||||
if !verbose {
|
||||
fmt.Fprintf(&sb, "%s:%d %s\n", r.File, r.Location.Line, firstLine)
|
||||
continue
|
||||
}
|
||||
nodeType := "unknown"
|
||||
if r.Node != nil {
|
||||
nodeType = r.Node.Type()
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
||||
}
|
||||
|
||||
case "location":
|
||||
for i := 0; i < renderCount; i++ {
|
||||
r := results[i]
|
||||
fmt.Fprintf(&sb, "%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()
|
||||
}
|
||||
fmt.Fprintf(&sb, "**%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] + "..."
|
||||
}
|
||||
fmt.Fprintf(&sb, "$%s=%s", name, capText)
|
||||
}
|
||||
first = false
|
||||
capText := cap.Text
|
||||
if len(capText) > 50 {
|
||||
capText = capText[:50] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("$%s=%s", name, capText))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if truncated {
|
||||
sb.WriteString(fmt.Sprintf("... and %d more matches (truncated)\n", len(results)-maxResults))
|
||||
if remaining > 0 {
|
||||
if cursorLine != "" {
|
||||
// Caller supplied a fully-formed "[cursor: ..., remaining: N]" footer.
|
||||
sb.WriteString(strings.TrimRight(cursorLine, "\n"))
|
||||
sb.WriteByte('\n')
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "[remaining: %d]\n", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// firstLineOf returns the first non-empty line of s, trimmed and capped at maxLen chars.
|
||||
func firstLineOf(s string, maxLen int) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(line) > maxLen {
|
||||
return line[:util.RuneBoundary(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) {
|
||||
reg := parser.NewRegistry()
|
||||
defer reg.Close()
|
||||
|
||||
@@ -4,23 +4,25 @@ import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||
)
|
||||
|
||||
// TestCompileRegexCaching tests that regex compilation is cached.
|
||||
func TestCompileRegexCaching(t *testing.T) {
|
||||
// Clear cache before test
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
pattern := `^test_\w+$`
|
||||
|
||||
// First compilation
|
||||
re1, err := compileRegex(pattern)
|
||||
re1, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("First compile failed: %v", err)
|
||||
}
|
||||
|
||||
// Second compilation should return cached version
|
||||
re2, err := compileRegex(pattern)
|
||||
re2, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Second compile failed: %v", err)
|
||||
}
|
||||
@@ -29,22 +31,12 @@ func TestCompileRegexCaching(t *testing.T) {
|
||||
if re1 != re2 {
|
||||
t.Error("Expected cached regex to be reused, got different objects")
|
||||
}
|
||||
|
||||
// Verify it's in the cache
|
||||
cached, ok := regexCache.Load(pattern)
|
||||
if !ok {
|
||||
t.Error("Pattern not found in cache")
|
||||
}
|
||||
|
||||
if cached.(*regexp.Regexp) != re1 {
|
||||
t.Error("Cached regex doesn't match returned regex")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompileRegexConcurrent tests concurrent regex compilation.
|
||||
func TestCompileRegexConcurrent(t *testing.T) {
|
||||
// Clear cache before test
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
pattern := `[a-z]+_\d+`
|
||||
const numGoroutines = 100
|
||||
@@ -60,7 +52,7 @@ func TestCompileRegexConcurrent(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
re, err := compileRegex(pattern)
|
||||
re, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
@@ -89,26 +81,30 @@ func TestCompileRegexConcurrent(t *testing.T) {
|
||||
// TestCompileRegexInvalidPattern tests error handling for invalid patterns.
|
||||
func TestCompileRegexInvalidPattern(t *testing.T) {
|
||||
// Clear cache before test
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
invalidPattern := `[invalid(`
|
||||
|
||||
_, err := compileRegex(invalidPattern)
|
||||
_, err := util.CompileRegex(invalidPattern)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid pattern, got nil")
|
||||
}
|
||||
|
||||
// Invalid patterns should not be cached
|
||||
_, ok := regexCache.Load(invalidPattern)
|
||||
if ok {
|
||||
t.Error("Invalid pattern should not be cached")
|
||||
// Verify that a valid pattern still works after an invalid one
|
||||
validPattern := `^valid$`
|
||||
re, err := util.CompileRegex(validPattern)
|
||||
if err != nil {
|
||||
t.Errorf("Expected valid pattern to compile, got error: %v", err)
|
||||
}
|
||||
if re == nil {
|
||||
t.Error("Expected non-nil regex for valid pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompileRegexMultiplePatterns tests that different patterns are cached separately.
|
||||
func TestCompileRegexMultiplePatterns(t *testing.T) {
|
||||
// Clear cache before test
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
patterns := []string{
|
||||
`^test_\w+$`,
|
||||
@@ -121,26 +117,14 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
|
||||
|
||||
// Compile all patterns
|
||||
for i, pattern := range patterns {
|
||||
re, err := compileRegex(pattern)
|
||||
re, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile failed for pattern %s: %v", pattern, err)
|
||||
}
|
||||
compiled[i] = re
|
||||
}
|
||||
|
||||
// Verify all are cached
|
||||
for i, pattern := range patterns {
|
||||
cached, ok := regexCache.Load(pattern)
|
||||
if !ok {
|
||||
t.Errorf("Pattern %s not in cache", pattern)
|
||||
}
|
||||
|
||||
if cached.(*regexp.Regexp) != compiled[i] {
|
||||
t.Errorf("Cached regex for %s doesn't match compiled version", pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// All should be different objects
|
||||
// All should be different objects (different patterns)
|
||||
for i := 0; i < len(compiled); i++ {
|
||||
for j := i + 1; j < len(compiled); j++ {
|
||||
if compiled[i] == compiled[j] {
|
||||
@@ -148,6 +132,17 @@ func TestCompileRegexMultiplePatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-compile should return cached versions
|
||||
for i, pattern := range patterns {
|
||||
re, err := util.CompileRegex(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Re-compile failed for pattern %s: %v", pattern, err)
|
||||
}
|
||||
if re != compiled[i] {
|
||||
t.Errorf("Pattern %s was not cached properly", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCompileRegex_Uncached benchmarks regex compilation without caching.
|
||||
@@ -163,23 +158,23 @@ func BenchmarkCompileRegex_Uncached(b *testing.B) {
|
||||
// BenchmarkCompileRegex_Cached benchmarks regex compilation with caching.
|
||||
func BenchmarkCompileRegex_Cached(b *testing.B) {
|
||||
// Clear cache
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
pattern := `^\w+_[0-9]{3,5}_[a-zA-Z]+$`
|
||||
|
||||
// Pre-populate cache
|
||||
_, _ = compileRegex(pattern)
|
||||
_, _ = util.CompileRegex(pattern)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = compileRegex(pattern)
|
||||
_, _ = util.CompileRegex(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCompileRegex_MixedPatterns benchmarks realistic workload with multiple patterns.
|
||||
func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
|
||||
// Clear cache
|
||||
regexCache = sync.Map{}
|
||||
util.ClearRegexCache()
|
||||
|
||||
patterns := []string{
|
||||
`^test_\w+$`,
|
||||
@@ -193,6 +188,6 @@ func BenchmarkCompileRegex_MixedPatterns(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Simulate realistic access pattern
|
||||
pattern := patterns[i%len(patterns)]
|
||||
_, _ = compileRegex(pattern)
|
||||
_, _ = util.CompileRegex(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+164
-47
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
@@ -131,6 +132,11 @@ func (s *Searcher) Search(ctx context.Context, req *Request) (*SearchResults, er
|
||||
WithRemediation("Provide a non-empty search pattern")
|
||||
}
|
||||
|
||||
// Validate that at least one provided path is allowed
|
||||
if err := s.validatePaths(req.Paths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build ripgrep command
|
||||
args := s.buildArgs(req)
|
||||
|
||||
@@ -214,10 +220,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
|
||||
args = append(args, "--no-ignore")
|
||||
}
|
||||
|
||||
// Max count per file to limit results
|
||||
if req.MaxResults > 0 {
|
||||
args = append(args, fmt.Sprintf("--max-count=%d", req.MaxResults))
|
||||
}
|
||||
// Result cap enforced in-process by parseOutput. rg has no cross-file
|
||||
// total-count flag in stable releases, so we don't pass one; --max-count is
|
||||
// per-file and would miss results unevenly.
|
||||
|
||||
// Add pattern
|
||||
args = append(args, "--", req.Pattern)
|
||||
@@ -237,15 +242,42 @@ func (s *Searcher) buildArgs(req *Request) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// validatePaths checks that at least one caller-provided path is allowed.
|
||||
// Returns an error if paths were provided but none passed IsPathAllowed.
|
||||
func (s *Searcher) validatePaths(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil // no explicit paths — will default to workspace root
|
||||
}
|
||||
for _, p := range paths {
|
||||
if s.cfg.IsPathAllowed(p) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New(errors.ErrPathNotAllowed, "all provided search paths are outside the workspace root").
|
||||
WithContext("paths", fmt.Sprintf("%v", paths)).
|
||||
WithRemediation("Provide paths within the workspace root")
|
||||
}
|
||||
|
||||
// resultPathAllowed reports whether a ripgrep result path is within the
|
||||
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
|
||||
// to it, so a relative path is joined to the root before the check; IsPathAllowed
|
||||
// then resolves symlinks, rejecting any path that --follow led outside the root.
|
||||
func (s *Searcher) resultPathAllowed(p string) bool {
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(s.cfg.WorkspaceRoot, p)
|
||||
}
|
||||
return s.cfg.IsPathAllowed(p)
|
||||
}
|
||||
|
||||
// parseOutput parses ripgrep JSON output.
|
||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||
results := &SearchResults{
|
||||
Results: []Result{},
|
||||
}
|
||||
|
||||
// Track context by file and line
|
||||
contextBefore := make(map[string][]string) // file -> lines before current match
|
||||
currentFile := ""
|
||||
// Track before-context lines linearly: accumulate context lines until the next match consumes them.
|
||||
var pendingBefore []string
|
||||
pendingFile := ""
|
||||
|
||||
scanner := bufio.NewScanner(output)
|
||||
for scanner.Scan() {
|
||||
@@ -267,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
continue
|
||||
}
|
||||
|
||||
// Defense in depth: never surface a file outside the workspace root.
|
||||
// ripgrep with --follow (default on) can traverse a symlink whose
|
||||
// target lives outside the root; dropping those matches keeps search
|
||||
// from leaking external file content.
|
||||
if !s.resultPathAllowed(match.Path.Text) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check max results
|
||||
if maxResults > 0 && len(results.Results) >= maxResults {
|
||||
results.Truncated = true
|
||||
@@ -285,14 +325,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
result.Column = match.Submatches[0].Start + 1 // 1-indexed
|
||||
}
|
||||
|
||||
// Add context before
|
||||
if ctx, ok := contextBefore[match.Path.Text]; ok {
|
||||
result.Context.Before = ctx
|
||||
delete(contextBefore, match.Path.Text)
|
||||
// Attach pending before-context if it belongs to this file
|
||||
if pendingFile == match.Path.Text && len(pendingBefore) > 0 {
|
||||
result.Context.Before = pendingBefore
|
||||
}
|
||||
pendingBefore = nil
|
||||
pendingFile = ""
|
||||
|
||||
results.Results = append(results.Results, result)
|
||||
currentFile = match.Path.Text
|
||||
|
||||
case "context":
|
||||
var ctx rgContext
|
||||
@@ -302,19 +342,20 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
|
||||
lineText := strings.TrimRight(ctx.Lines.Text, "\n\r")
|
||||
|
||||
// Determine if this is before or after context
|
||||
isAfter := false
|
||||
if len(results.Results) > 0 {
|
||||
lastResult := &results.Results[len(results.Results)-1]
|
||||
if lastResult.File == ctx.Path.Text && ctx.LineNumber > lastResult.Line {
|
||||
// This is after context
|
||||
lastResult.Context.After = append(lastResult.Context.After, lineText)
|
||||
} else if ctx.Path.Text == currentFile || currentFile == "" {
|
||||
// This is before context for a potential upcoming match
|
||||
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
|
||||
last := &results.Results[len(results.Results)-1]
|
||||
if last.File == ctx.Path.Text && ctx.LineNumber > last.Line {
|
||||
last.Context.After = append(last.Context.After, lineText)
|
||||
isAfter = true
|
||||
}
|
||||
} else {
|
||||
// Before any match - store as potential before context
|
||||
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
|
||||
}
|
||||
if !isAfter {
|
||||
if pendingFile != ctx.Path.Text {
|
||||
pendingBefore = nil
|
||||
pendingFile = ctx.Path.Text
|
||||
}
|
||||
pendingBefore = append(pendingBefore, lineText)
|
||||
}
|
||||
|
||||
case "summary":
|
||||
@@ -333,8 +374,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FormatResults formats search results for display.
|
||||
// FormatOptions controls how search results are rendered.
|
||||
type FormatOptions struct {
|
||||
Cluster bool // coalesce consecutive matches into line-range blocks
|
||||
CursorLine string // if non-empty, appended as a footer line
|
||||
Verbose bool // if true, emit "Found N matches in M files:" preamble (opt-in)
|
||||
}
|
||||
|
||||
// FormatResults formats search results for display (backward-compat wrapper).
|
||||
func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
return s.FormatResultsWithOptions(results, FormatOptions{})
|
||||
}
|
||||
|
||||
// FormatResultsWithOptions formats search results with configurable output.
|
||||
// By default the "Found N matches in M files:" preamble is omitted; set opts.Verbose=true to restore it.
|
||||
func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatOptions) string {
|
||||
if len(results.Results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
@@ -351,14 +405,20 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
fileResults[r.File] = append(fileResults[r.File], r)
|
||||
}
|
||||
|
||||
// Write summary
|
||||
totalMatches := len(results.Results)
|
||||
fileCount := len(fileResults)
|
||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
||||
if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
||||
// Write preamble only when Verbose is requested.
|
||||
if opts.Verbose {
|
||||
totalMatches := len(results.Results)
|
||||
fileCount := len(fileResults)
|
||||
fmt.Fprintf(&sb, "Found %d matches in %d files", totalMatches, fileCount)
|
||||
if results.Truncated {
|
||||
fmt.Fprintf(&sb, " (truncated, total: %d)", results.Total)
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
} else if results.Truncated && opts.CursorLine == "" {
|
||||
// When a cursor footer is present it already reports the remaining count,
|
||||
// so the prose truncation line would be redundant.
|
||||
fmt.Fprintf(&sb, "(truncated, showing subset of %d total matches)\n\n", results.Total)
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
|
||||
// Write results grouped by file
|
||||
for _, file := range fileOrder {
|
||||
@@ -370,32 +430,89 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
||||
fmt.Fprintf(&sb, "**%s**\n", relPath)
|
||||
|
||||
for _, r := range fileResults[file] {
|
||||
// Write context before
|
||||
for _, ctx := range r.Context.Before {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
|
||||
// Write match line
|
||||
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
|
||||
|
||||
// Write context after
|
||||
for _, ctx := range r.Context.After {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
if opts.Cluster {
|
||||
writeClusteredResults(&sb, fileResults[file])
|
||||
} else {
|
||||
writeVerboseResults(&sb, fileResults[file])
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if opts.CursorLine != "" {
|
||||
sb.WriteString(opts.CursorLine)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// truncateLine truncates a line if it exceeds maxLen.
|
||||
// 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 bytes, backing off to a
|
||||
// rune boundary so a multibyte character is never split into invalid UTF-8.
|
||||
func truncateLine(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
+130
-10
@@ -8,10 +8,72 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
)
|
||||
|
||||
// parseOutput must not attach one file's pending before-context to a match in a
|
||||
// different file (cross-file leak guard in the JSON stream parser).
|
||||
func TestParseOutputCrossFileContextNotLeaked(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
var buf bytes.Buffer
|
||||
// fileA emits a before-context with no following match in fileA; then fileB
|
||||
// has a match. fileA's pending context must NOT attach to fileB's match.
|
||||
buf.WriteString(`{"type":"context","data":{"path":{"text":"fileA.go"},"lines":{"text":"orphan a ctx\n"},"line_number":1}}` + "\n")
|
||||
buf.WriteString(`{"type":"match","data":{"path":{"text":"fileB.go"},"lines":{"text":"hit B\n"},"line_number":5,"submatches":[{"match":{"text":"hit"},"start":0,"end":3}]}}` + "\n")
|
||||
|
||||
res, err := s.parseOutput(&buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOutput: %v", err)
|
||||
}
|
||||
if len(res.Results) != 1 {
|
||||
t.Fatalf("want 1 result, got %d", len(res.Results))
|
||||
}
|
||||
if res.Results[0].File != "fileB.go" {
|
||||
t.Errorf("result file = %q, want fileB.go", res.Results[0].File)
|
||||
}
|
||||
if len(res.Results[0].Context.Before) != 0 {
|
||||
t.Errorf("fileA context leaked into fileB match: %v", res.Results[0].Context.Before)
|
||||
}
|
||||
}
|
||||
|
||||
// parseOutput must attach a same-file before-context to the following match.
|
||||
func TestParseOutputSameFileBeforeContext(t *testing.T) {
|
||||
s := newTestSearcher(t)
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`{"type":"context","data":{"path":{"text":"f.go"},"lines":{"text":"before line\n"},"line_number":4}}` + "\n")
|
||||
buf.WriteString(`{"type":"match","data":{"path":{"text":"f.go"},"lines":{"text":"the match\n"},"line_number":5,"submatches":[{"match":{"text":"match"},"start":4,"end":9}]}}` + "\n")
|
||||
|
||||
res, err := s.parseOutput(&buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOutput: %v", err)
|
||||
}
|
||||
if len(res.Results) != 1 {
|
||||
t.Fatalf("want 1 result, got %d", len(res.Results))
|
||||
}
|
||||
got := res.Results[0].Context.Before
|
||||
if len(got) != 1 || got[0] != "before line" {
|
||||
t.Errorf("same-file before-context not attached: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateLine must back off to a rune boundary so multibyte characters are
|
||||
// never split into invalid UTF-8.
|
||||
func TestTruncateLineRuneSafe(t *testing.T) {
|
||||
s := strings.Repeat("é", 200) // 2 bytes each → 400 bytes
|
||||
out := truncateLine(s, 50)
|
||||
if !utf8.ValidString(out) {
|
||||
t.Errorf("truncated output is not valid UTF-8: %q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, "...") {
|
||||
t.Errorf("expected ellipsis suffix, got %q", out)
|
||||
}
|
||||
if got := truncateLine("hi", 50); got != "hi" {
|
||||
t.Errorf("short string should be unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
@@ -43,9 +105,10 @@ func TestBuildArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *Request
|
||||
expected []string
|
||||
name string
|
||||
req *Request
|
||||
expected []string
|
||||
notExpected []string // T-06: verify absence of unexpected flags
|
||||
}{
|
||||
{
|
||||
name: "basic search",
|
||||
@@ -54,7 +117,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
ContextLines: 2,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--context=2", "--", "test", "."},
|
||||
expected: []string{"--json", "--context=2", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=0"},
|
||||
},
|
||||
{
|
||||
name: "ignore case",
|
||||
@@ -63,7 +127,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
IgnoreCase: true,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
||||
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
||||
notExpected: []string{"--fixed-strings"},
|
||||
},
|
||||
{
|
||||
name: "fixed strings",
|
||||
@@ -71,7 +136,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
Pattern: "test",
|
||||
Regex: false,
|
||||
},
|
||||
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
||||
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case"},
|
||||
},
|
||||
{
|
||||
name: "with file types",
|
||||
@@ -80,7 +146,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
FileTypes: []string{"go", "ts"},
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
|
||||
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case", "--fixed-strings"},
|
||||
},
|
||||
{
|
||||
name: "with max results",
|
||||
@@ -89,7 +156,8 @@ func TestBuildArgs(t *testing.T) {
|
||||
MaxResults: 10,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--max-count=10", "--", "test", "."},
|
||||
expected: []string{"--json", "--", "test", "."},
|
||||
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,6 +178,16 @@ func TestBuildArgs(t *testing.T) {
|
||||
t.Errorf("expected arg %q not found in %v", exp, args)
|
||||
}
|
||||
}
|
||||
|
||||
// T-06: Check that unexpected args are absent
|
||||
for _, notExp := range tt.notExpected {
|
||||
for _, arg := range args {
|
||||
if arg == notExp {
|
||||
t.Errorf("unexpected arg %q found in %v", notExp, args)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -259,7 +337,7 @@ func TestSearchIntegration(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Create test files
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
@@ -269,7 +347,7 @@ func main() {
|
||||
println("Hello, World!")
|
||||
}
|
||||
`
|
||||
err = os.WriteFile(testFile, []byte(content), 0600)
|
||||
err = os.WriteFile(testFile, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
@@ -324,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
|
||||
t.Error("expected error for empty pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
|
||||
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
|
||||
// is outside the workspace root. Such matches must never be returned — search
|
||||
// must not leak the content of files outside the workspace.
|
||||
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
|
||||
const secret = "SUPERSECRETLEAKCANARY"
|
||||
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
// A regular in-workspace file that does NOT contain the secret.
|
||||
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink inside the workspace pointing at the outside directory.
|
||||
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
|
||||
t.Skipf("symlinks unsupported: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.WorkspaceRoot = workspace
|
||||
if !cfg.FollowSymlinks {
|
||||
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
s, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Skip("ripgrep not installed")
|
||||
}
|
||||
|
||||
results, err := s.Search(context.Background(), &Request{Pattern: secret})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
for _, r := range results.Results {
|
||||
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
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 TestASTQueryFormatCompactDefault(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Default (no format arg) is compact: one line per match, no code blocks.
|
||||
args := map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
}
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = args
|
||||
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")
|
||||
}
|
||||
if text := res.Content[0].(mcp.TextContent).Text; strings.Contains(text, "```") {
|
||||
t.Errorf("compact default should NOT have code blocks, got:\n%s", text)
|
||||
}
|
||||
|
||||
// Verbose remains available on explicit request.
|
||||
args["format"] = "verbose"
|
||||
req.Params.Arguments = args
|
||||
res, err = srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if text := res.Content[0].(mcp.TextContent).Text; !strings.Contains(text, "```") {
|
||||
t.Errorf("format=verbose 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
|
||||
// by default (they flood results with third-party code) but still searches them
|
||||
// when the caller targets one explicitly via paths (the walk root is never skipped).
|
||||
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deps := map[string]string{
|
||||
"node_modules": "NodeModulesDep",
|
||||
"vendor": "VendorDep",
|
||||
}
|
||||
for dir, fn := range deps {
|
||||
full := filepath.Join(tmpDir, dir)
|
||||
if err := os.MkdirAll(full, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
|
||||
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default: workspace-root query must skip the dependency dirs.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"format": "location",
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("default query error: %v", err)
|
||||
}
|
||||
// location format prints file:line, so assert on paths.
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "a.go") {
|
||||
t.Errorf("default query should find top-level a.go, got:\n%s", text)
|
||||
}
|
||||
for dir := range deps {
|
||||
if strings.Contains(text, dir+string(filepath.Separator)) {
|
||||
t.Errorf("default query must skip %s, got:\n%s", dir, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit target: querying inside node_modules must still find its code.
|
||||
req2 := mcp.CallToolRequest{}
|
||||
req2.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
|
||||
"format": "location",
|
||||
}
|
||||
res2, err := srv.handleASTQuery(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("explicit-target query error: %v", err)
|
||||
}
|
||||
text2 := res2.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
|
||||
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelPath verifies workspace-relative display paths (token saving) with a
|
||||
// safe fallback to the original path for anything outside the root.
|
||||
func TestRelPath(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
|
||||
t.Errorf("in-workspace path should be relativized, got %q", got)
|
||||
}
|
||||
// Outside the workspace root → returned unchanged (never a ../ escape string).
|
||||
outside := "/definitely/outside/x.go"
|
||||
if got := srv.relPath(outside); got != outside {
|
||||
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||
|
||||
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
||||
// cursor with an accurate remaining count when more matches exist. Previously
|
||||
// the page-1 walk was capped at max_results, so the total was truncated and the
|
||||
// cursor fired nondeterministically (and with a wrong remaining count).
|
||||
func TestASTQueryFirstPageCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Fixture has 5 functions (Alpha/Beta/Gamma/Delta/Epsilon); cap at 2.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() $RET",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"max_results": float64(2),
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("page1 error: %v", err)
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "[cursor:") {
|
||||
t.Fatalf("page1 with 5 matches capped at 2 must emit a cursor, got:\n%s", text)
|
||||
}
|
||||
// 5 total - 2 shown = 3 remaining.
|
||||
if !strings.Contains(text, "remaining: 3") {
|
||||
t.Errorf("expected accurate 'remaining: 3', got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASTQueryPaginationCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
|
||||
// page is capped by max_results but more matches exist, the response MUST carry
|
||||
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
|
||||
// with no cursor, silently breaking pagination on the common path.
|
||||
func TestFileSearchFirstPageCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if srv.searcher == nil {
|
||||
t.Skip("rg not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"max_results": float64(1),
|
||||
"context_lines": float64(0),
|
||||
}
|
||||
res, err := srv.handleFileSearch(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("file_search error: %v", err)
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "[cursor:") {
|
||||
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
|
||||
}
|
||||
// The dead-end prose must not be used when a cursor is available.
|
||||
if strings.Contains(text, "(truncated, showing subset") {
|
||||
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSearchPaginationCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if srv.searcher == nil {
|
||||
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,351 @@
|
||||
// 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, 50)
|
||||
|
||||
p.format = request.GetString("format", "")
|
||||
if p.format == "" {
|
||||
if prefsFormat != "" {
|
||||
p.format = prefsFormat
|
||||
} else {
|
||||
// Default to compact (one line per match): far fewer tokens than
|
||||
// verbose (full code + captures) on multi-match queries. Callers can
|
||||
// opt into verbose per-call or via the default_format session pref.
|
||||
p.format = "compact"
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
// A full walk is required on every page: the cursor's "remaining" count is
|
||||
// derived from the total match count (see buildASTCursorFooter), so the page-1
|
||||
// response can only emit a correct cursor if it has seen every match. Capping
|
||||
// page 1 at max_results made the cursor fire nondeterministically (only when
|
||||
// the last parsed file overshot the cap) with a wrong remaining count. Parse
|
||||
// results are LRU-cached, so re-walking on later pages is mostly file I/O, and
|
||||
// matches below max_results already visit the whole tree regardless.
|
||||
var allResults []query.MatchResult
|
||||
for _, searchPath := range p.paths {
|
||||
if !s.cfg.IsPathAllowed(searchPath) {
|
||||
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() {
|
||||
// Skip hidden and dependency dirs (third-party code that would
|
||||
// flood results), but never the walk root itself — the caller
|
||||
// explicitly targeted it, e.g. paths:["vendor/foo"].
|
||||
if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !hasAnySuffix(path, exts) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip oversized files before reading them off disk.
|
||||
if info.Size() > s.cfg.MaxFileSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
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...)
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
s.logger.Warn("error walking path", "path", searchPath, "error", walkErr)
|
||||
}
|
||||
}
|
||||
return allResults
|
||||
}
|
||||
|
||||
// isDependencyDir reports whether name is a well-known third-party dependency
|
||||
// directory that ast_query skips by default. These hold vendored/installed code,
|
||||
// not the caller's source, and parsing them wastes time and floods results with
|
||||
// irrelevant matches. Target one explicitly via paths to query inside it.
|
||||
func isDependencyDir(name string) bool {
|
||||
switch name {
|
||||
case "node_modules", "vendor", "bower_components":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnySuffix reports whether path ends with any of the given suffixes.
|
||||
func hasAnySuffix(path string, suffixes []string) bool {
|
||||
for _, ext := range suffixes {
|
||||
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)
|
||||
|
||||
// The formatter emits cursorFooter in place of its "[remaining: N]" hint when
|
||||
// results are truncated; no fragile placeholder string-replacement needed.
|
||||
output := query.FormatResultsWithCursor(allResults, p.maxResults, p.format, p.offset, p.verbose, cursorFooter)
|
||||
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
|
||||
|
||||
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
|
||||
sb.WriteString("Symbols:\n")
|
||||
|
||||
for _, sym := range symbols {
|
||||
kindStr := symbolKindIcon(sym.Kind)
|
||||
fmt.Fprintf(&sb, " %s %s L%d\n", kindStr, sym.Name, sym.Location.Line)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// relPath renders path relative to the workspace root for display, saving the
|
||||
// tokens an absolute path would cost. Falls back to the original path when it
|
||||
// can't be relativized (outside the root, or on error).
|
||||
func (s *Server) relPath(path string) string {
|
||||
if absPath, err := filepath.Abs(path); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// symbolKindIcon returns an icon/prefix for a symbol kind.
|
||||
func symbolKindIcon(kind protocol.SymbolKind) string {
|
||||
switch kind {
|
||||
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,133 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// new_content arrives already fully decoded by the JSON-RPC layer (mcp-go):
|
||||
// JSON escapes such as \n, \t, \\, \" have been resolved to their real bytes.
|
||||
// It must therefore be used verbatim — any further unescaping would corrupt
|
||||
// legitimate backslash sequences in source code (string literals, regexes,
|
||||
// Windows paths). See TestEditApplyPreservesBackslashSequences*.
|
||||
newContent := request.GetString("new_content", "")
|
||||
|
||||
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":
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
|
||||
default:
|
||||
// Fallback: treat unknown values as "diff" for safety.
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// fencedDiff wraps a unified diff in a ```diff code fence. No "Diff:" label is
|
||||
// added — the fence and the diff's own ---/+++ headers already identify it, so
|
||||
// the label would only cost tokens.
|
||||
func fencedDiff(diff string) string {
|
||||
return "```diff\n" + diff + "```\n"
|
||||
}
|
||||
|
||||
// countDiffLines counts added (+) and removed (-) content lines in a unified diff.
|
||||
// Only lines inside hunks are counted (everything after the first "@@" header), so the
|
||||
// "---"/"+++" file headers are skipped structurally — and content whose own text starts
|
||||
// with + or - is counted correctly rather than mistaken for a header. The git-style
|
||||
// "\ No newline at end of file" marker is ignored.
|
||||
func countDiffLines(diff string) (added, removed int) {
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk || line == "" {
|
||||
continue
|
||||
}
|
||||
switch line[0] {
|
||||
case '+':
|
||||
added++
|
||||
case '-':
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// callEdit invokes handleEditApply with the given args and returns the result.
|
||||
func callEdit(t *testing.T, srv *Server, args map[string]any) *mcp.CallToolResult {
|
||||
t.Helper()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = args
|
||||
res, err := srv.handleEditApply(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleEditApply error: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("handleEditApply returned nil result")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestEditApplyPreservesBackslashSequencesText is the primary regression guard for the
|
||||
// double-unescape bug: new_content arrives from the JSON-RPC layer already fully decoded,
|
||||
// so the handler must write it to disk verbatim. Literal backslash sequences (\n, \t, \",
|
||||
// \\) that legitimately appear in source code must survive byte-for-byte.
|
||||
func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
f := writeFile(t, tmpDir, "note.txt", "PLACEHOLDER\n")
|
||||
|
||||
// Exactly the bytes a client intends after JSON decoding: backslash sequences are
|
||||
// real source text here, NOT escapes to be interpreted.
|
||||
newContent := `printf("a\nb\tc"); re = \d+; path = "C:\\tmp"; q = \"`
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_text": "PLACEHOLDER",
|
||||
"new_content": newContent,
|
||||
"response": "none",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
want := newContent + "\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("new_content not written verbatim (double-unescape regression).\nwant: %q\ngot: %q", want, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditApplyDiffResponseFencedNoLabel verifies response=diff emits the unified
|
||||
// diff in a ```diff fence with no redundant "Diff:" label (the fence and the diff's
|
||||
// own headers already identify it; the label would only cost tokens).
|
||||
func TestEditApplyDiffResponseFencedNoLabel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
f := writeFile(t, tmpDir, "note.txt", "before\n")
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_text": "before",
|
||||
"new_content": "after",
|
||||
"response": "diff",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
out := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.HasPrefix(out, "```diff\n") {
|
||||
t.Errorf("diff response should start with the ```diff fence, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Diff:") {
|
||||
t.Errorf("diff response should not carry a redundant 'Diff:' label, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountDiffLinesHunkAware verifies the line counter skips file headers structurally and
|
||||
// counts content lines whose own text begins with + or - (previously dropped as "+++"/"---").
|
||||
func TestCountDiffLinesHunkAware(t *testing.T) {
|
||||
diff := "--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n context\n+++plusprefix\n+normal add\n---minusprefix\n-normal del\n"
|
||||
added, removed := countDiffLines(diff)
|
||||
if added != 2 || removed != 2 {
|
||||
t.Fatalf("hunk-aware diff count wrong: added=%d removed=%d (want 2 and 2)", added, removed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditApplyPreservesBackslashSequencesCode covers the AST/code path: the same verbatim
|
||||
// guarantee must hold for syntactically-validated code files.
|
||||
func TestEditApplyPreservesBackslashSequencesCode(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
src := "package main\n\nfunc demo() {\n\tprintln(\"old\")\n}\n"
|
||||
f := writeFile(t, tmpDir, "demo.go", src)
|
||||
|
||||
// Real tab indentation + literal backslash sequences inside string/raw-string literals.
|
||||
newBody := "func demo() {\n\tprintln(\"a\\nb\\tc\")\n\t_ = `\\d+`\n\t_ = \"C:\\\\tmp\"\n}"
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_kind": "function_declaration",
|
||||
"selector_name": "demo",
|
||||
"new_content": newBody,
|
||||
"response": "none",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(got), newBody) {
|
||||
t.Fatalf("backslash sequences corrupted in code edit.\nwant substring:\n%q\ngot file:\n%q", newBody, string(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// Package server implements the MCP server for file operations.
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"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/fuzzy"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// Symbol-suggestion tuning for not-found symbol_name reads.
|
||||
const (
|
||||
// maxSymbolSuggestionDistance is the max fuzzy edit distance for a suggestion.
|
||||
maxSymbolSuggestionDistance = 3
|
||||
// maxSymbolSuggestions caps how many "did you mean" names are listed.
|
||||
maxSymbolSuggestions = 3
|
||||
)
|
||||
|
||||
// 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", 0)
|
||||
|
||||
// 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
|
||||
}
|
||||
// Default to the configured cap (not 0/unbounded) so a broad pattern can't
|
||||
// flood memory and the context window; an explicit max_results still wins,
|
||||
// and the cursor footer lets the caller page through the rest.
|
||||
maxResults := effectiveInt(request, "max_results", prefsMaxResults, s.cfg.MaxSearchResults)
|
||||
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,
|
||||
IncludeHidden: request.GetBool("include_hidden", false),
|
||||
}
|
||||
|
||||
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
|
||||
switch {
|
||||
case maxResults > 0 && len(results.Results) > maxResults:
|
||||
// Cursor pages (offset>0) fetch all results, so cap here and emit a cursor.
|
||||
remaining := len(results.Results) - maxResults
|
||||
results.Results = results.Results[:maxResults]
|
||||
results.Truncated = true
|
||||
nextOffset := offset + maxResults
|
||||
nextCursor := cursor.Encode(nextOffset, queryHash)
|
||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||
case offset == 0 && maxResults > 0 && results.Truncated:
|
||||
// First page: ripgrep capped us at exactly maxResults in parseOutput, but
|
||||
// more matches exist. Emit a cursor so the caller can page instead of being
|
||||
// told "truncated" with no way to continue. remaining derives from rg's
|
||||
// total match count (a submatch count — an upper-bound proxy for rows).
|
||||
nextCursor := cursor.Encode(maxResults, queryHash)
|
||||
remaining := max(results.Total-maxResults, 1)
|
||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||
}
|
||||
|
||||
s.logger.Info("search completed",
|
||||
"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 {
|
||||
fmt.Fprintf(&output, "--- %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.
|
||||
fmt.Fprintf(&output, "--- %s ---\n[duplicate of %s, etag: %s]\n", p, firstPath, etag)
|
||||
continue
|
||||
}
|
||||
seenEtag[etag] = p
|
||||
}
|
||||
fmt.Fprintf(&output, "--- %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),
|
||||
// Compact prefix ("12│content") is the default — it drops the 4-wide
|
||||
// padding and trailing space of the legacy " 12│ " form, saving ~3
|
||||
// chars/line with no information loss. Pass compact_line_numbers=false
|
||||
// (or mode/line_numbers="full") to restore the padded alignment.
|
||||
compactLineNums: request.GetBool("compact_line_numbers", true),
|
||||
}
|
||||
|
||||
// Apply session line_numbers pref when no explicit per-call override was supplied.
|
||||
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", ""))
|
||||
candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind)
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("symbol %q not found in %s%s", symbolName, path, s.nearestSymbolSuggestion(ctx, path, content, symbolName))
|
||||
case 1:
|
||||
lineStart = candidates[0].StartLine
|
||||
lineEnd = candidates[0].EndLine
|
||||
default:
|
||||
// Ambiguous: returning the first match risks reading the wrong
|
||||
// symbol. List candidates so the caller can disambiguate.
|
||||
return "", fmt.Errorf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or read a specific one with line_start/line_end",
|
||||
symbolName, path, len(candidates), formatSymbolCandidates(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// resolveSymbolCandidates parses the AST and returns every symbol matching the
|
||||
// name (and optional kind). Zero results means not found; more than one means an
|
||||
// ambiguous lookup. Returns nil on parse failure (treated as not found).
|
||||
func (s *Server) resolveSymbolCandidates(ctx context.Context, path string, content []byte, symbolName string, symbolKind protocol.SymbolKind) []parser.SymbolCandidate {
|
||||
result, err := s.parser.Parse(ctx, path, content)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parser.FindSymbolCandidates(result.Tree, content, path, symbolName, symbolKind)
|
||||
}
|
||||
|
||||
// nearestSymbolSuggestion returns a " — did you mean: a, b, c?" hint listing the
|
||||
// closest symbol names in the file to name, or "" if none are reasonably close.
|
||||
// The re-parse is cheap: the tree is served from the parser cache.
|
||||
func (s *Server) nearestSymbolSuggestion(ctx context.Context, path string, content []byte, name string) string {
|
||||
result, err := s.parser.Parse(ctx, path, content)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
syms := parser.ExtractSymbols(result.Tree, content, result.Language, path)
|
||||
if len(syms) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(syms))
|
||||
seen := make(map[string]bool, len(syms))
|
||||
for _, sym := range syms {
|
||||
if !seen[sym.Name] {
|
||||
seen[sym.Name] = true
|
||||
names = append(names, sym.Name)
|
||||
}
|
||||
}
|
||||
matches := fuzzy.New(maxSymbolSuggestionDistance).Match(name, names)
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
top := matches
|
||||
if len(top) > maxSymbolSuggestions {
|
||||
top = top[:maxSymbolSuggestions]
|
||||
}
|
||||
suggestions := make([]string, len(top))
|
||||
for i, m := range top {
|
||||
suggestions[i] = m.Text
|
||||
}
|
||||
return " — did you mean: " + strings.Join(suggestions, ", ") + "?"
|
||||
}
|
||||
|
||||
// formatSymbolCandidates renders ambiguous symbol matches as a compact,
|
||||
// comma-separated list, e.g. "method_declaration L10-24, method_declaration L42-58".
|
||||
func formatSymbolCandidates(candidates []parser.SymbolCandidate) string {
|
||||
parts := make([]string, len(candidates))
|
||||
for i, c := range candidates {
|
||||
parts[i] = fmt.Sprintf("%s L%d-%d", c.NodeType, c.StartLine, c.EndLine)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// 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.
|
||||
// countLines returns the number of source lines in content. Unlike
|
||||
// len(splitLines(...)), it does not over-count newline-terminated files
|
||||
// (a trailing "\n" does not introduce a phantom final line) and avoids
|
||||
// materializing the line slice.
|
||||
func countLines(content []byte) int {
|
||||
if len(content) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := bytes.Count(content, []byte{'\n'})
|
||||
if content[len(content)-1] != '\n' {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
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,935 @@
|
||||
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 ----
|
||||
|
||||
// A misspelled symbol_name should fail with a "did you mean" hint listing the
|
||||
// closest real symbols, so the model can self-correct.
|
||||
func TestReadSymbolNotFoundSuggestsNearest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
goSrc := "package main\n\nfunc Hello() {}\n\nfunc Goodbye() {}\n"
|
||||
f := writeFile(t, tmpDir, "main.go", goSrc)
|
||||
|
||||
out := callRead(t, srv, map[string]interface{}{
|
||||
"path": f,
|
||||
"symbol_name": "Helo", // typo for Hello
|
||||
})
|
||||
|
||||
if !strings.Contains(out, "not found") {
|
||||
t.Errorf("expected not-found error, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "did you mean") || !strings.Contains(out, "Hello") {
|
||||
t.Errorf("expected suggestion of Hello, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
content := "line one\nline two\n"
|
||||
f := writeFile(t, tmpDir, "test.txt", content)
|
||||
|
||||
// Default is now the compact prefix (token-saving): "1│line one".
|
||||
def := callRead(t, srv, map[string]interface{}{"path": f})
|
||||
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
|
||||
t.Errorf("default should use compact prefix, got:\n%s", def)
|
||||
}
|
||||
|
||||
// Explicit opt-out restores the padded alignment.
|
||||
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
|
||||
if !strings.Contains(padded, " 1│ line one") {
|
||||
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Default line numbers use the compact prefix.
|
||||
if !strings.Contains(out, "1│package main") {
|
||||
t.Errorf("default should have compact line numbers, got:\n%s", out)
|
||||
}
|
||||
// etag present
|
||||
if !strings.Contains(out, "[etag:") {
|
||||
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,324 @@
|
||||
// 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/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
"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
|
||||
}
|
||||
|
||||
if !s.cfg.IsPathAllowed(file) {
|
||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||
}
|
||||
|
||||
// Position can be given directly (line+column) or resolved from a symbol name.
|
||||
var line, col int
|
||||
if symbolName := request.GetString("symbol_name", ""); symbolName != "" {
|
||||
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
||||
resolvedLine, resolvedCol, errResult := s.resolveLSPSymbolPosition(ctx, file, symbolName, symbolKind)
|
||||
if errResult != nil {
|
||||
return errResult, nil
|
||||
}
|
||||
line, col = resolvedLine, resolvedCol
|
||||
} else {
|
||||
line = request.GetInt("line", 0)
|
||||
if line <= 0 {
|
||||
return mcp.NewToolResultError("line must be positive (or provide symbol_name)"), nil
|
||||
}
|
||||
col = request.GetInt("column", 0)
|
||||
if col <= 0 {
|
||||
return mcp.NewToolResultError("column must be positive (or provide symbol_name)"), 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 > true.
|
||||
// Compact (one line per file) is the default — it preserves every
|
||||
// line:col pair while cutting ~one line per reference for the LLM consumer.
|
||||
var prefsCompact *bool
|
||||
if sp := s.sessionPrefs.Load(); sp != nil {
|
||||
prefsCompact = sp.CompactRefs
|
||||
}
|
||||
compact := effectiveBool(request, "compact", prefsCompact, true)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
|
||||
// its name identifier, for use as an LSP position. Returns an error result when the
|
||||
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
|
||||
// or the name is ambiguous (listing candidates).
|
||||
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
|
||||
}
|
||||
result, err := s.parser.Parse(ctx, file, content)
|
||||
if err != nil {
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
|
||||
}
|
||||
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
|
||||
case 1:
|
||||
return candidates[0].NameLine, candidates[0].NameColumn, nil
|
||||
default:
|
||||
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
|
||||
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 — LSP unavailable)\n\n")
|
||||
} else {
|
||||
// Always signal the degraded result so the model knows this is an AST
|
||||
// guess, not real LSP type info, and can decide whether to retry.
|
||||
output.WriteString("[AST fallback — LSP unavailable]\n")
|
||||
}
|
||||
fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
|
||||
fmt.Fprintf(&output, "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 {
|
||||
fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
|
||||
}
|
||||
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||
|
||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||
if preview != "" {
|
||||
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 {
|
||||
// Relativize for display; references are not re-read so the absolute
|
||||
// path isn't needed downstream.
|
||||
filePath := s.relPath(lsp.URIToFile(loc.URI))
|
||||
if _, seen := fileGroups[filePath]; !seen {
|
||||
fileOrder = append(fileOrder, filePath)
|
||||
}
|
||||
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 {
|
||||
fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
|
||||
}
|
||||
|
||||
for _, filePath := range fileOrder {
|
||||
locs := fileGroups[filePath]
|
||||
if compact {
|
||||
output.WriteString(formatReferencesCompact(filePath, locs))
|
||||
} else {
|
||||
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
|
||||
for _, loc := range locs {
|
||||
fmt.Fprintf(&output, " 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 = "> "
|
||||
}
|
||||
fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
|
||||
}
|
||||
|
||||
return preview.String()
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// errorText extracts the text payload from an error CallToolResult.
|
||||
func errorText(res *mcp.CallToolResult) string {
|
||||
if res == nil || len(res.Content) == 0 {
|
||||
return ""
|
||||
}
|
||||
if tc, ok := res.Content[0].(mcp.TextContent); ok {
|
||||
return tc.Text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLSPSymbolPosition underpins lsp_query's symbol_name affordance: it maps
|
||||
// a symbol name to the line:column of its name identifier, or returns an error
|
||||
// result for not-found / ambiguous names. It does not require a running LSP.
|
||||
func TestResolveLSPSymbolPosition(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
|
||||
goSrc := "package main\n" + // 1
|
||||
"\n" + // 2
|
||||
"type A struct{}\n" + // 3
|
||||
"func (a A) Run() {}\n" + // 4
|
||||
"\n" + // 5
|
||||
"type B struct{}\n" + // 6
|
||||
"func (b B) Run() {}\n" + // 7
|
||||
"\n" + // 8
|
||||
"func Unique() {}\n" // 9
|
||||
f := writeFile(t, tmpDir, "main.go", goSrc)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("unique symbol resolves to its name position", func(t *testing.T) {
|
||||
line, col, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Unique", "")
|
||||
if errRes != nil {
|
||||
t.Fatalf("unexpected error result for Unique: %s", errorText(errRes))
|
||||
}
|
||||
if line != 9 || col != 6 {
|
||||
t.Errorf("Unique position = %d:%d, want 9:6", line, col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous symbol errors", func(t *testing.T) {
|
||||
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Run", "")
|
||||
if errRes == nil {
|
||||
t.Fatal("expected ambiguity error for Run")
|
||||
}
|
||||
if !strings.Contains(errorText(errRes), "ambiguous") {
|
||||
t.Errorf("expected 'ambiguous' in error, got: %s", errorText(errRes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing symbol errors", func(t *testing.T) {
|
||||
_, _, errRes := srv.resolveLSPSymbolPosition(ctx, f, "Nope", "")
|
||||
if errRes == nil {
|
||||
t.Fatal("expected not-found error for Nope")
|
||||
}
|
||||
if !strings.Contains(errorText(errRes), "not found") {
|
||||
t.Errorf("expected 'not found' in error, got: %s", errorText(errRes))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 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: 0). |\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` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
|
||||
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
||||
"| `max_results` | Page size (default: 50). |\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" +
|
||||
"## Matching semantics (important)\n\n" +
|
||||
"Matching is keyword- and kind-based, not full structural matching. The leading keyword " +
|
||||
"(`func`/`function`/`class`/`struct`/`interface`) plus placeholders select the node KIND; " +
|
||||
"argument shapes, return types, and the literal bound to `$NAME` are NOT enforced as filters. " +
|
||||
"So `func $NAME($$$ARGS) error` matches every function/method, not only those returning `error`. " +
|
||||
"Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" +
|
||||
"For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " +
|
||||
"these ARE applied as filters.\n\n" +
|
||||
"## Scope\n\n" +
|
||||
"The walk skips hidden directories (dot-prefixed) and dependency directories " +
|
||||
"(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " +
|
||||
"flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " +
|
||||
"the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" +
|
||||
"## Examples\n\n" +
|
||||
"```json\n" +
|
||||
"// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
|
||||
`{"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" +
|
||||
"## Position: line+column or symbol_name\n\n" +
|
||||
"Give the position directly with `line`+`column`, or pass `symbol_name` (optionally `symbol_kind`) to " +
|
||||
"resolve it from the AST — handy when you know the name but not the coordinates. Ambiguous or unknown " +
|
||||
"names return an error (with candidates / a 'did you mean' hint).\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 by symbol name — no line/column needed\n" +
|
||||
`{"action": "definition", "file": "server.go", "symbol_name": "NewServer"}` + "\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" +
|
||||
"## new_content handling\n\n" +
|
||||
"`new_content` is inserted **verbatim**. Send it as normal JSON — real newlines (or JSON `\\n`), with quotes JSON-escaped as usual. The server does not re-interpret escape sequences, so literal backslash sequences in source (`\\n`, `\\t`, `\\\\`, regexes, Windows paths) are written exactly as given. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF).\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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+199
-757
File diff suppressed because it is too large
Load Diff
+447
-92
@@ -5,7 +5,9 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -29,8 +31,8 @@ func TestNew(t *testing.T) {
|
||||
|
||||
if srv == nil {
|
||||
t.Fatal("New() returned nil server")
|
||||
return
|
||||
}
|
||||
|
||||
if srv.cfg != cfg {
|
||||
t.Error("server config mismatch")
|
||||
}
|
||||
@@ -48,44 +50,6 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
srv, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
|
||||
result, err := srv.handlePing(ctx, req)
|
||||
if err != nil {
|
||||
t.Errorf("handlePing() error = %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handlePing() returned nil result")
|
||||
}
|
||||
|
||||
// Check that the result contains "pong"
|
||||
contents := result.Content
|
||||
if len(contents) == 0 {
|
||||
t.Fatal("handlePing() returned empty content")
|
||||
}
|
||||
|
||||
textContent, ok := contents[0].(mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatal("handlePing() did not return text content")
|
||||
}
|
||||
|
||||
if textContent.Text != "pong" {
|
||||
t.Errorf("handlePing() = %v, want 'pong'", textContent.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileRead(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -123,6 +87,7 @@ func Hello() {
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleFileRead() returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
contents := result.Content
|
||||
@@ -179,6 +144,7 @@ func Hello() {
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleFileRead() returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
contents := result.Content
|
||||
@@ -270,6 +236,7 @@ func Goodbye() error {
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleASTQuery() returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
contents := result.Content
|
||||
@@ -278,55 +245,6 @@ func Goodbye() error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEditPreview(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
content := `package main
|
||||
|
||||
func Hello() {
|
||||
println("Hello")
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
srv, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"file": testFile,
|
||||
"operation": "replace",
|
||||
"selector_kind": "function_declaration",
|
||||
"selector_name": "Hello",
|
||||
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
||||
}
|
||||
|
||||
result, err := srv.handleEdit(ctx, req, false)
|
||||
if err != nil {
|
||||
t.Errorf("handleEdit(preview) error = %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleEdit(preview) returned nil result")
|
||||
}
|
||||
|
||||
// Verify file was NOT modified (it's just a preview)
|
||||
fileContent, _ := os.ReadFile(testFile)
|
||||
if string(fileContent) != content {
|
||||
t.Error("handleEdit(preview) should not modify the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEditApply(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -360,18 +278,455 @@ func Hello() {
|
||||
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
||||
}
|
||||
|
||||
result, err := srv.handleEdit(ctx, req, true)
|
||||
result, err := srv.handleEditApply(ctx, req)
|
||||
if err != nil {
|
||||
t.Errorf("handleEdit(apply) error = %v", err)
|
||||
t.Errorf("handleEditApply error = %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleEdit(apply) returned nil result")
|
||||
t.Fatal("handleEditApply returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify file WAS modified
|
||||
fileContent, _ := os.ReadFile(testFile)
|
||||
if string(fileContent) == content {
|
||||
t.Error("handleEdit(apply) should modify the file")
|
||||
t.Error("handleEditApply should modify the file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleFileReadMaxFileSize verifies that handleFileRead rejects files
|
||||
// exceeding MaxFileSize via os.Stat before loading them into memory (T-03, S-01).
|
||||
func TestHandleFileReadMaxFileSize(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tmpDir, "big.txt")
|
||||
content := make([]byte, 1024) // 1KB file
|
||||
for i := range content {
|
||||
content[i] = 'A'
|
||||
}
|
||||
if err := os.WriteFile(testFile, content, 0600); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// Set MaxFileSize smaller than the file
|
||||
cfg := &config.Config{
|
||||
WorkspaceRoot: tmpDir,
|
||||
EnableLSP: false,
|
||||
MaxFileSize: 512, // 512 bytes — smaller than our 1KB file
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
srv, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"path": testFile,
|
||||
}
|
||||
|
||||
result, err := srv.handleFileRead(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleFileRead() returned Go error: %v", err)
|
||||
}
|
||||
|
||||
// The result should indicate an error (file too large)
|
||||
if result == nil {
|
||||
t.Fatal("handleFileRead() returned nil result")
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Error("expected IsError=true for file exceeding MaxFileSize")
|
||||
}
|
||||
contents := result.Content
|
||||
if len(contents) == 0 {
|
||||
t.Fatal("expected non-empty content with error message")
|
||||
}
|
||||
textContent, ok := contents[0].(mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatal("expected text content")
|
||||
}
|
||||
if !strings.Contains(textContent.Text, "too large") {
|
||||
t.Errorf("expected 'too large' error message, got: %s", textContent.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitLinesLargeFile tests the splitLines function with a large file (>1MB)
|
||||
// to exercise the bufio.Scanner path including the scanner.Err() check (T-07, C-05).
|
||||
func TestSplitLinesLargeFile(t *testing.T) {
|
||||
// Build a string >1MB with known line count
|
||||
lineCount := 20000
|
||||
var sb strings.Builder
|
||||
for i := 0; i < lineCount; i++ {
|
||||
sb.WriteString(strings.Repeat("x", 60))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
largeContent := sb.String()
|
||||
|
||||
// Verify it's large enough to trigger the scanner path
|
||||
if len(largeContent) <= 1024*1024 {
|
||||
t.Fatalf("test content too small: %d bytes, need >1MB", len(largeContent))
|
||||
}
|
||||
|
||||
lines := splitLines(largeContent)
|
||||
|
||||
// String ends with \n, so splitLines adds an empty trailing element
|
||||
// (matching the behavior of strings.Split for the small-file path)
|
||||
expectedLines := lineCount + 1 // lineCount lines + 1 trailing empty
|
||||
if len(lines) != expectedLines {
|
||||
t.Errorf("splitLines returned %d lines, expected %d", len(lines), expectedLines)
|
||||
}
|
||||
|
||||
// Check first and last actual lines
|
||||
if lines[0] != strings.Repeat("x", 60) {
|
||||
t.Errorf("first line mismatch: got %q", lines[0][:20])
|
||||
}
|
||||
if lines[lineCount-1] != strings.Repeat("x", 60) {
|
||||
t.Errorf("last content line mismatch: got %q", lines[lineCount-1][:20])
|
||||
}
|
||||
if lines[lineCount] != "" {
|
||||
t.Errorf("expected empty trailing line, got %q", lines[lineCount])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitLinesLargeFileNoTrailingNewline verifies splitLines for large files
|
||||
// without a trailing newline.
|
||||
func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
|
||||
lineCount := 20000
|
||||
var sb strings.Builder
|
||||
for i := 0; i < lineCount; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString(strings.Repeat("y", 60))
|
||||
}
|
||||
largeContent := sb.String()
|
||||
|
||||
if len(largeContent) <= 1024*1024 {
|
||||
t.Fatalf("test content too small: %d bytes", len(largeContent))
|
||||
}
|
||||
|
||||
lines := splitLines(largeContent)
|
||||
if len(lines) != lineCount {
|
||||
t.Errorf("splitLines returned %d lines, expected %d", len(lines), lineCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountLines verifies countLines reports source line counts without the
|
||||
// phantom trailing line that len(splitLines(...)) produces for newline-terminated
|
||||
// files.
|
||||
func TestCountLines(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want int
|
||||
}{
|
||||
{name: "empty", content: "", want: 0},
|
||||
{name: "single line no newline", content: "a", want: 1},
|
||||
{name: "single line trailing newline", content: "a\n", want: 1},
|
||||
{name: "two lines trailing newline", content: "a\nb\n", want: 2},
|
||||
{name: "two lines no trailing newline", content: "a\nb", want: 2},
|
||||
{name: "blank lines", content: "\n\n\n", want: 3},
|
||||
{name: "crlf trailing", content: "a\r\nb\r\n", want: 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := countLines([]byte(tt.content)); got != tt.want {
|
||||
t.Errorf("countLines(%q) = %d, want %d", tt.content, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
|
||||
if strings.Contains(text, " 1│") {
|
||||
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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++ {
|
||||
fmt.Fprintf(&sb, "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++ {
|
||||
fmt.Fprintf(&sb, "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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package util
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
|
||||
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
|
||||
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
|
||||
// instead so a multibyte character is never split into an invalid encoding.
|
||||
func RuneBoundary(s string, i int) int {
|
||||
if i <= 0 {
|
||||
return 0
|
||||
}
|
||||
if i >= len(s) {
|
||||
return len(s)
|
||||
}
|
||||
for i > 0 && !utf8.RuneStart(s[i]) {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestRuneBoundary(t *testing.T) {
|
||||
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
|
||||
const cafe = "café"
|
||||
cases := []struct {
|
||||
name string
|
||||
s string
|
||||
i int
|
||||
want int
|
||||
}{
|
||||
{"negative clamps to zero", "abc", -1, 0},
|
||||
{"zero", "abc", 0, 0},
|
||||
{"beyond length clamps to len", "abc", 99, 3},
|
||||
{"ascii exact boundary", "abcdef", 4, 4},
|
||||
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
|
||||
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
|
||||
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := RuneBoundary(c.s, c.i)
|
||||
if got != c.want {
|
||||
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
|
||||
}
|
||||
// Invariant: the prefix is always valid UTF-8.
|
||||
if !utf8.ValidString(c.s[:got]) {
|
||||
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -77,24 +77,24 @@ func (e *StructuredError) Error() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Error code and message
|
||||
sb.WriteString(fmt.Sprintf("[%d] %s", e.Code, e.Message))
|
||||
fmt.Fprintf(&sb, "[%d] %s", e.Code, e.Message)
|
||||
|
||||
// Context if available
|
||||
if len(e.Context) > 0 {
|
||||
sb.WriteString("\nContext:")
|
||||
for k, v := range e.Context {
|
||||
sb.WriteString(fmt.Sprintf("\n %s: %v", k, v))
|
||||
fmt.Fprintf(&sb, "\n %s: %v", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Remediation if available
|
||||
if e.Remediation != "" {
|
||||
sb.WriteString(fmt.Sprintf("\nHow to fix: %s", e.Remediation))
|
||||
fmt.Fprintf(&sb, "\nHow to fix: %s", e.Remediation)
|
||||
}
|
||||
|
||||
// Underlying cause if available
|
||||
if e.Cause != nil {
|
||||
sb.WriteString(fmt.Sprintf("\nCaused by: %v", e.Cause))
|
||||
fmt.Fprintf(&sb, "\nCaused by: %v", e.Cause)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -120,6 +120,42 @@ func (e *StructuredError) WithRemediation(msg string) *StructuredError {
|
||||
return e
|
||||
}
|
||||
|
||||
// UserMessage returns a user-safe error message without internal details.
|
||||
// This should be used for API responses to avoid leaking implementation details.
|
||||
func (e *StructuredError) UserMessage() string {
|
||||
// Return only the message and remediation, no stack trace, no cause details
|
||||
if e.Remediation != "" {
|
||||
return fmt.Sprintf("%s. %s", e.Message, e.Remediation)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// SanitizeError returns a user-safe error message from any error.
|
||||
// For StructuredError, it returns UserMessage().
|
||||
// For other errors, it returns a generic message to avoid leaking internals.
|
||||
func SanitizeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if it's a StructuredError
|
||||
if se, ok := err.(*StructuredError); ok {
|
||||
return se.UserMessage()
|
||||
}
|
||||
|
||||
// For other errors, extract only the basic message
|
||||
// Avoid exposing full paths or implementation details
|
||||
msg := err.Error()
|
||||
|
||||
// Truncate very long messages that might contain stack traces
|
||||
const maxLen = 200
|
||||
if len(msg) > maxLen {
|
||||
msg = msg[:maxLen] + "..."
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// New creates a new structured error with stack trace.
|
||||
func New(code ErrorCode, message string) *StructuredError {
|
||||
return &StructuredError{
|
||||
@@ -179,7 +215,7 @@ func captureStack(skip int) string {
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !strings.Contains(frame.File, "runtime/") {
|
||||
sb.WriteString(fmt.Sprintf("\n %s:%d %s", frame.File, frame.Line, frame.Function))
|
||||
fmt.Fprintf(&sb, "\n %s:%d %s", frame.File, frame.Line, frame.Function)
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
|
||||
+11
-13
@@ -1,6 +1,8 @@
|
||||
// Package protocol defines shared types used across the MCP file operations server.
|
||||
package protocol
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// Location represents a position in a file.
|
||||
type Location struct {
|
||||
File string `json:"file"`
|
||||
@@ -30,6 +32,8 @@ const (
|
||||
SymbolProperty SymbolKind = "property"
|
||||
SymbolModule SymbolKind = "module"
|
||||
SymbolPackage SymbolKind = "package"
|
||||
SymbolEnum SymbolKind = "enum"
|
||||
SymbolTrait SymbolKind = "trait"
|
||||
)
|
||||
|
||||
// Symbol represents a code symbol (function, class, variable, etc.).
|
||||
@@ -60,12 +64,14 @@ const (
|
||||
LangVue Language = "vue"
|
||||
LangJSON Language = "json"
|
||||
LangYAML Language = "yaml"
|
||||
LangElixir Language = "elixir"
|
||||
LangRust Language = "rust"
|
||||
LangUnknown Language = "unknown"
|
||||
)
|
||||
|
||||
// DetectLanguage detects the language from a filename.
|
||||
func DetectLanguage(filename string) Language {
|
||||
ext := getExtension(filename)
|
||||
ext := filepath.Ext(filename)
|
||||
switch ext {
|
||||
case ".go":
|
||||
return LangGo
|
||||
@@ -87,19 +93,11 @@ func DetectLanguage(filename string) Language {
|
||||
return LangJSON
|
||||
case ".yaml", ".yml":
|
||||
return LangYAML
|
||||
case ".ex", ".exs":
|
||||
return LangElixir
|
||||
case ".rs":
|
||||
return LangRust
|
||||
default:
|
||||
return LangUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func getExtension(filename string) string {
|
||||
for i := len(filename) - 1; i >= 0; i-- {
|
||||
if filename[i] == '.' {
|
||||
return filename[i:]
|
||||
}
|
||||
if filename[i] == '/' || filename[i] == '\\' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ func TestDetectLanguage(t *testing.T) {
|
||||
{"index.html", LangHTML},
|
||||
{"page.htm", LangHTML},
|
||||
{"Component.vue", LangVue},
|
||||
{"app.ex", LangElixir},
|
||||
{"app_test.exs", LangElixir},
|
||||
{"mix.exs", LangElixir},
|
||||
{"unknown.txt", LangUnknown},
|
||||
{"README", LangUnknown},
|
||||
{"path/to/file.go", LangGo},
|
||||
{"path/to/file.ts", LangTypeScript},
|
||||
{"path/to/file.ex", LangElixir},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -44,26 +48,3 @@ func TestDetectLanguage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
{"file.go", ".go"},
|
||||
{"file.test.go", ".go"},
|
||||
{"path/to/file.ts", ".ts"},
|
||||
{"noextension", ""},
|
||||
{".hidden", ".hidden"},
|
||||
{"file.", "."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
result := getExtension(tt.filename)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getExtension(%q) = %q, want %q", tt.filename, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+180
-7
@@ -20,15 +20,15 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
# Functions
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
printf "${GREEN}[INFO]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
@@ -86,6 +86,108 @@ check_dependencies() {
|
||||
fi
|
||||
}
|
||||
|
||||
detect_package_manager() {
|
||||
if command -v brew &> /dev/null; then
|
||||
echo "brew"
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
echo "apt"
|
||||
elif command -v yum &> /dev/null; then
|
||||
echo "yum"
|
||||
elif command -v pacman &> /dev/null; then
|
||||
echo "pacman"
|
||||
elif command -v apk &> /dev/null; then
|
||||
echo "apk"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
install_prerequisites() {
|
||||
local pkg_mgr="$1"
|
||||
local install_lsp="${2:-false}"
|
||||
|
||||
print_info "Checking prerequisites..."
|
||||
|
||||
# Check if ripgrep is installed
|
||||
if ! command -v rg &> /dev/null; then
|
||||
print_warn "ripgrep not found - required for file search functionality"
|
||||
|
||||
case "$pkg_mgr" in
|
||||
brew)
|
||||
print_info "Installing ripgrep via Homebrew..."
|
||||
brew install ripgrep
|
||||
;;
|
||||
apt)
|
||||
print_info "Installing ripgrep via apt..."
|
||||
sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
;;
|
||||
yum)
|
||||
print_info "Installing ripgrep via yum..."
|
||||
sudo yum install -y ripgrep
|
||||
;;
|
||||
pacman)
|
||||
print_info "Installing ripgrep via pacman..."
|
||||
sudo pacman -S --noconfirm ripgrep
|
||||
;;
|
||||
apk)
|
||||
print_info "Installing ripgrep via apk..."
|
||||
sudo apk add --no-cache ripgrep
|
||||
;;
|
||||
unknown)
|
||||
print_error "Could not detect package manager"
|
||||
print_error "Please install ripgrep manually: https://github.com/BurntSushi/ripgrep"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
print_info "✓ ripgrep is already installed"
|
||||
fi
|
||||
|
||||
if [ "$install_lsp" = "true" ]; then
|
||||
print_info "Installing LSP servers for enhanced IDE features..."
|
||||
|
||||
# Install gopls (Go LSP)
|
||||
if command -v go &> /dev/null && ! command -v gopls &> /dev/null; then
|
||||
print_info "Installing gopls (Go language server)..."
|
||||
go install golang.org/x/tools/gopls@latest
|
||||
fi
|
||||
|
||||
# Install typescript-language-server (TypeScript/JavaScript)
|
||||
if command -v npm &> /dev/null && ! command -v typescript-language-server &> /dev/null; then
|
||||
print_info "Installing typescript-language-server..."
|
||||
npm install -g typescript-language-server typescript
|
||||
fi
|
||||
|
||||
# Install pylsp (Python LSP)
|
||||
if command -v pip3 &> /dev/null && ! python3 -c "import pylsp" 2>/dev/null; then
|
||||
print_info "Installing python-lsp-server..."
|
||||
pip3 install python-lsp-server
|
||||
fi
|
||||
|
||||
# Install clangd (C/C++)
|
||||
if ! command -v clangd &> /dev/null; then
|
||||
case "$pkg_mgr" in
|
||||
brew)
|
||||
print_info "Installing clangd via Homebrew..."
|
||||
brew install llvm
|
||||
;;
|
||||
apt)
|
||||
print_info "Installing clangd via apt..."
|
||||
sudo apt-get install -y clangd
|
||||
;;
|
||||
yum)
|
||||
print_info "Installing clangd via yum..."
|
||||
sudo yum install -y clang-tools-extra
|
||||
;;
|
||||
pacman)
|
||||
print_info "Installing clangd via pacman..."
|
||||
sudo pacman -S --noconfirm clang
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
local version
|
||||
version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
|
||||
@@ -125,11 +227,34 @@ download_and_install() {
|
||||
else
|
||||
print_info "Verifying checksum..."
|
||||
cd "$tmpdir"
|
||||
if grep "$archive_name" checksums.txt | sha256sum -c --status; then
|
||||
print_info "Checksum verification passed"
|
||||
|
||||
# Extract expected checksum for our archive
|
||||
local expected_checksum
|
||||
expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
|
||||
|
||||
if [ -z "$expected_checksum" ]; then
|
||||
print_warn "Checksum not found for $archive_name, skipping verification"
|
||||
else
|
||||
print_error "Checksum verification failed"
|
||||
exit 1
|
||||
# Calculate actual checksum (use shasum on macOS, sha256sum on Linux)
|
||||
local actual_checksum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
actual_checksum=$(sha256sum "$archive_name" | awk '{print $1}')
|
||||
elif command -v shasum &> /dev/null; then
|
||||
actual_checksum=$(shasum -a 256 "$archive_name" | awk '{print $1}')
|
||||
else
|
||||
print_warn "No checksum utility found, skipping verification"
|
||||
cd - > /dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$expected_checksum" = "$actual_checksum" ]; then
|
||||
print_info "Checksum verification passed"
|
||||
else
|
||||
print_error "Checksum verification failed"
|
||||
print_error "Expected: $expected_checksum"
|
||||
print_error "Actual: $actual_checksum"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
cd - > /dev/null
|
||||
fi
|
||||
@@ -183,6 +308,37 @@ main() {
|
||||
print_info "MCP Filepuff Installation Script"
|
||||
echo ""
|
||||
|
||||
# Parse command line arguments
|
||||
local install_lsp="false"
|
||||
local skip_prereqs="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--with-lsp)
|
||||
install_lsp="true"
|
||||
shift
|
||||
;;
|
||||
--skip-prereqs)
|
||||
skip_prereqs="true"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: install.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --with-lsp Install LSP servers (gopls, typescript-language-server, pylsp, clangd)"
|
||||
echo " --skip-prereqs Skip prerequisite installation (ripgrep, LSP servers)"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
print_error "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
|
||||
@@ -191,6 +347,18 @@ main() {
|
||||
platform=$(detect_platform)
|
||||
print_info "Detected platform: $platform"
|
||||
|
||||
# Detect package manager
|
||||
local pkg_mgr
|
||||
pkg_mgr=$(detect_package_manager)
|
||||
print_info "Detected package manager: $pkg_mgr"
|
||||
echo ""
|
||||
|
||||
# Install prerequisites unless skipped
|
||||
if [ "$skip_prereqs" != "true" ]; then
|
||||
install_prerequisites "$pkg_mgr" "$install_lsp"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Get latest version
|
||||
local version
|
||||
version=$(get_latest_version)
|
||||
@@ -209,6 +377,11 @@ main() {
|
||||
echo ""
|
||||
|
||||
print_info "To get started, run: $BINARY_NAME --help"
|
||||
|
||||
if [ "$install_lsp" != "true" ]; then
|
||||
echo ""
|
||||
print_info "Tip: Run with --with-lsp to install LSP servers for enhanced IDE features"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
|
||||
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);
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Invoked by lukaszraczylo/shared-actions go-release-cgo.yaml before GoReleaser,
|
||||
# with TARGET_GOOS/TARGET_GOARCH set from the build matrix entry.
|
||||
#
|
||||
# GoReleaser Pro `release --split` partitions builds across matrix runners.
|
||||
# Its default partition is by GOOS only, so every linux runner would build
|
||||
# BOTH linux/amd64 and linux/arm64. With CGO_ENABLED=1 (go-tree-sitter needs
|
||||
# CGO) the native gcc on one runner cannot assemble the other arch's CGO
|
||||
# runtime (`gcc_arm64.S: no such instruction`), failing the release.
|
||||
#
|
||||
# Exporting GGOOS/GGOARCH restricts each runner to its own target. These are
|
||||
# filter-only vars (unlike GOOS/GOARCH, which bleed into before-hooks such as
|
||||
# `go run`/`go generate`) and require `partial.by: target` in .goreleaser.yaml.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ] && [ -n "${TARGET_GOOS:-}" ] && [ -n "${TARGET_GOARCH:-}" ]; then
|
||||
echo "GGOOS=${TARGET_GOOS}" >>"${GITHUB_ENV}"
|
||||
echo "GGOARCH=${TARGET_GOARCH}" >>"${GITHUB_ENV}"
|
||||
echo "Pinned GoReleaser split target: GGOOS=${TARGET_GOOS} GGOARCH=${TARGET_GOARCH}"
|
||||
fi
|
||||
Reference in New Issue
Block a user