mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62b7e0019 | ||
|
|
e98ec6032c | ||
|
|
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 |
@@ -1,4 +1,4 @@
|
|||||||
name: Autoupdate go.mod and go.sum
|
name: Update dependencies
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -6,16 +6,16 @@ on:
|
|||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
|
||||||
actions: write
|
actions: write
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
autoupdate:
|
update:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
release-workflow: "release.yaml"
|
node: false
|
||||||
|
release-workflow: release.yaml
|
||||||
lfs: true
|
lfs: true
|
||||||
secrets: inherit
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.26"
|
||||||
rolling-release-tag: "v1"
|
rolling-release-tag: "v1"
|
||||||
platforms: >-
|
platforms: >-
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.25'
|
go-version: '1.26'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install govulncheck
|
- name: Install govulncheck
|
||||||
|
|||||||
+14
-20
@@ -2,7 +2,7 @@ version: "2"
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
go: '1.25'
|
go: '1.26'
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
@@ -13,32 +13,26 @@ linters:
|
|||||||
- unused # Check for unused code
|
- unused # Check for unused code
|
||||||
- gocyclo # Check cyclomatic complexity
|
- gocyclo # Check cyclomatic complexity
|
||||||
- misspell # Check for misspelled words
|
- 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:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gofmt # Check formatting
|
- gofmt # Check formatting
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 15
|
|
||||||
errcheck:
|
|
||||||
check-blank: true
|
|
||||||
govet:
|
|
||||||
enable-all: true
|
|
||||||
staticcheck:
|
|
||||||
checks: ["all"]
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-rules:
|
|
||||||
# Exclude some linters from running on tests
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- gocyclo
|
|
||||||
- errcheck
|
|
||||||
- gosec
|
|
||||||
|
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|
||||||
output:
|
output:
|
||||||
sort-results: true
|
sort-order:
|
||||||
|
- file
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ 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:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
|
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ Search for text patterns in files using ripgrep.
|
|||||||
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
|
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
|
||||||
- `ignore_case`: Case insensitive search
|
- `ignore_case`: Case insensitive search
|
||||||
- `regex`: Treat pattern as regex (default: true)
|
- `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
|
- `max_results`: Maximum number of results to return
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -352,7 +352,7 @@ Search for AST patterns in code files using structural pattern matching.
|
|||||||
- `name_matches`: Regex pattern to filter by name
|
- `name_matches`: Regex pattern to filter by name
|
||||||
- `name_exact`: Exact name to match
|
- `name_exact`: Exact name to match
|
||||||
- `kind_in`: Node types to match (e.g., function_declaration)
|
- `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**:
|
**Examples**:
|
||||||
```json
|
```json
|
||||||
|
|||||||
+2
-2
@@ -70,7 +70,7 @@ Search for text patterns in files using ripgrep. Supports regex patterns, file t
|
|||||||
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
|
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
|
||||||
| `ignore_case` | `boolean` | No | Case insensitive search |
|
| `ignore_case` | `boolean` | No | Case insensitive search |
|
||||||
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
|
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
|
||||||
| `context_lines` | `number` | No | Number of context lines around matches (default: 2) |
|
| `context_lines` | `number` | No | Number of context lines around matches (default: 0) |
|
||||||
| `max_results` | `number` | No | Maximum number of results to return |
|
| `max_results` | `number` | No | Maximum number of results to return |
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
@@ -153,7 +153,7 @@ Search for AST patterns in code files. Use code patterns with $VAR placeholders
|
|||||||
| `name_matches` | `string` | No | Regex pattern to filter by name |
|
| `name_matches` | `string` | No | Regex pattern to filter by name |
|
||||||
| `name_exact` | `string` | No | Exact name to match |
|
| `name_exact` | `string` | No | Exact name to match |
|
||||||
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
|
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
|
||||||
| `max_results` | `number` | No | Maximum number of results to return (default: 100) |
|
| `max_results` | `number` | No | Maximum number of results to return (default: 50) |
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
module github.com/lukaszraczylo/mcp-filepuff
|
module github.com/lukaszraczylo/mcp-filepuff
|
||||||
|
|
||||||
go 1.25.5
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/goccy/go-json v0.10.6
|
github.com/goccy/go-json v0.11.2
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/mark3labs/mcp-go v0.55.0
|
github.com/mark3labs/mcp-go v1.2.1
|
||||||
github.com/sergi/go-diff v1.4.0
|
github.com/sergi/go-diff v1.4.0
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -15,8 +15,8 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
|
|||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
@@ -24,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.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
|
github.com/mark3labs/mcp-go v1.2.1 h1:gd8VD3feMAVycwAM0x1nhv42Q1oLLjfOyowoUzHULB8=
|
||||||
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
github.com/mark3labs/mcp-go v1.2.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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
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/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
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 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
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=
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||||
@@ -44,8 +44,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const (
|
|||||||
DefaultSearchTimeout = 30 * time.Second
|
DefaultSearchTimeout = 30 * time.Second
|
||||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||||
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
||||||
DefaultMaxSearchResults = 1000
|
DefaultMaxSearchResults = 50
|
||||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||||
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
||||||
)
|
)
|
||||||
|
|||||||
+35
-31
@@ -63,17 +63,46 @@ type Engine struct {
|
|||||||
registry *parser.Registry
|
registry *parser.Registry
|
||||||
dmp *diffmatchpatch.DiffMatchPatch
|
dmp *diffmatchpatch.DiffMatchPatch
|
||||||
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
|
||||||
|
// 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 {
|
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{
|
return &Engine{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
dmp: diffmatchpatch.New(),
|
dmp: diffmatchpatch.New(),
|
||||||
fileLocks: sync.Map{},
|
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.
|
// 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.
|
// This prevents concurrent edits to the same file which could cause corruption.
|
||||||
func (e *Engine) lockFile(filePath string) func() {
|
func (e *Engine) lockFile(filePath string) func() {
|
||||||
@@ -179,14 +208,7 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
|
|||||||
|
|
||||||
// Apply changes if requested
|
// Apply changes if requested
|
||||||
if apply {
|
if apply {
|
||||||
// Preserve original file permissions
|
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||||
fileInfo, err := os.Stat(edit.File)
|
|
||||||
perm := os.FileMode(0o600) // default fallback
|
|
||||||
if err == nil {
|
|
||||||
perm = fileInfo.Mode().Perm()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
|
||||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||||
return &EditResult{
|
return &EditResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -236,14 +258,7 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
|
|||||||
|
|
||||||
// Apply changes if requested
|
// Apply changes if requested
|
||||||
if apply {
|
if apply {
|
||||||
// Preserve original file permissions
|
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
|
||||||
fileInfo, err := os.Stat(edit.File)
|
|
||||||
perm := os.FileMode(0o600) // default fallback
|
|
||||||
if err == nil {
|
|
||||||
perm = fileInfo.Mode().Perm()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
|
|
||||||
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
structuredErr := errors.NewFileNotWritableError(edit.File, err)
|
||||||
return &EditResult{
|
return &EditResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -866,14 +881,3 @@ func truncateString(s string, maxLen int) string {
|
|||||||
}
|
}
|
||||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateLanguage checks if AST editing is supported for a file.
|
|
||||||
// Returns nil for supported languages, error for unsupported.
|
|
||||||
// Note: Text-based editing is always available regardless of this check.
|
|
||||||
func ValidateLanguage(filename string) error {
|
|
||||||
lang := protocol.DetectLanguage(filename)
|
|
||||||
if lang == protocol.LangUnknown {
|
|
||||||
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -369,6 +369,73 @@ func Hello() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyRevalidatesPathBeforeWrite verifies the write-time admission re-check:
|
||||||
|
// an Engine whose validator rejects the target must refuse the write and leave
|
||||||
|
// the file untouched (the TOCTOU guard against a symlink swapped in after the
|
||||||
|
// handler's original IsPathAllowed check).
|
||||||
|
func TestApplyRevalidatesPathBeforeWrite(t *testing.T) {
|
||||||
|
registry := parser.NewRegistry()
|
||||||
|
defer registry.Close()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "test.go")
|
||||||
|
original := "package main\n\nfunc Hello() {}\n"
|
||||||
|
if err := os.WriteFile(tmpFile, []byte(original), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validator denies the path (as IsPathAllowed would for a path that now
|
||||||
|
// resolves outside the workspace root).
|
||||||
|
e := NewEngineWithValidator(registry, func(string) bool { return false })
|
||||||
|
|
||||||
|
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||||
|
File: tmpFile,
|
||||||
|
Operation: EditReplace,
|
||||||
|
Selector: ASTSelector{Kind: "function_declaration"},
|
||||||
|
NewContent: "func Evil() {}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply returned transport error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Success || result.Applied {
|
||||||
|
t.Errorf("rejected-path edit must not apply (Success=%v Applied=%v)", result.Success, result.Applied)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(tmpFile); string(got) != original {
|
||||||
|
t.Errorf("file must be unchanged when path validation fails; got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyPreservesFileMode verifies an edit keeps the file's existing
|
||||||
|
// permission bits rather than resetting them.
|
||||||
|
func TestApplyPreservesFileMode(t *testing.T) {
|
||||||
|
registry := parser.NewRegistry()
|
||||||
|
defer registry.Close()
|
||||||
|
e := NewEngine(registry)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "script.go")
|
||||||
|
if err := os.WriteFile(tmpFile, []byte("package main\n\nfunc Hello() {}\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.Apply(context.Background(), &ASTEdit{
|
||||||
|
File: tmpFile,
|
||||||
|
Operation: EditReplace,
|
||||||
|
Selector: ASTSelector{Kind: "function_declaration"},
|
||||||
|
NewContent: "func Hi() {}",
|
||||||
|
})
|
||||||
|
if err != nil || !result.Success {
|
||||||
|
t.Fatalf("apply failed: err=%v result=%+v", err, result)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0o755 {
|
||||||
|
t.Errorf("file mode not preserved: got %o, want 0755", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
|
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
|
||||||
// keeps a single, consistent line-ending convention (a documented guarantee).
|
// keeps a single, consistent line-ending convention (a documented guarantee).
|
||||||
func TestApplyPreservesCRLF(t *testing.T) {
|
func TestApplyPreservesCRLF(t *testing.T) {
|
||||||
@@ -1751,3 +1818,80 @@ func TestRegressionLineMergeDelete(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFindExactTextIndexAndOverlap pins selector_index resolution for literal
|
||||||
|
// text matches, including overlapping matches and the out-of-range guard — the
|
||||||
|
// range these return is the span that gets mutated, so wrong indices = wrong-
|
||||||
|
// location writes.
|
||||||
|
func TestFindExactTextIndexAndOverlap(t *testing.T) {
|
||||||
|
e := NewEngine(parser.NewRegistry())
|
||||||
|
content := []byte("aaaa") // "aa" matches at 0,1,2 (overlapping)
|
||||||
|
|
||||||
|
for i, want := range []struct{ start, end int }{{0, 2}, {1, 3}, {2, 4}} {
|
||||||
|
start, end, err := e.findExactText(content, "aa", i)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||||
|
}
|
||||||
|
if start != want.start || end != want.end {
|
||||||
|
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := e.findExactText(content, "aa", 3); err == nil {
|
||||||
|
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||||
|
}
|
||||||
|
if _, _, err := e.findExactText(content, "zz", 0); err == nil {
|
||||||
|
t.Error("missing text must error")
|
||||||
|
}
|
||||||
|
if _, _, err := e.findExactText(content, "", 0); err == nil {
|
||||||
|
t.Error("empty text selector must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindRegexPatternIndexAndError pins selector_index and error paths for regex
|
||||||
|
// selection (non-overlapping matches via FindAllIndex).
|
||||||
|
func TestFindRegexPatternIndexAndError(t *testing.T) {
|
||||||
|
e := NewEngine(parser.NewRegistry())
|
||||||
|
content := []byte("a1b2c3") // [0-9] matches at 1,3,5
|
||||||
|
|
||||||
|
for i, want := range []struct{ start, end int }{{1, 2}, {3, 4}, {5, 6}} {
|
||||||
|
start, end, err := e.findRegexPattern(content, "[0-9]", i)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %d: unexpected error: %v", i, err)
|
||||||
|
}
|
||||||
|
if start != want.start || end != want.end {
|
||||||
|
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := e.findRegexPattern(content, "[0-9]", 3); err == nil {
|
||||||
|
t.Error("index 3 (only 3 matches) must be out-of-range error")
|
||||||
|
}
|
||||||
|
if _, _, err := e.findRegexPattern(content, "zzz", 0); err == nil {
|
||||||
|
t.Error("non-matching pattern must error")
|
||||||
|
}
|
||||||
|
if _, _, err := e.findRegexPattern(content, "[unclosed", 0); err == nil {
|
||||||
|
t.Error("invalid regex must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpliceContentCRLFNoBareLF verifies the CRLF pullback: when an edit range
|
||||||
|
// ends between the \r and \n of a terminator, the \r\n must stay intact in the
|
||||||
|
// tail rather than being split into a bare LF (which would corrupt the file's
|
||||||
|
// line-ending convention).
|
||||||
|
func TestSpliceContentCRLFNoBareLF(t *testing.T) {
|
||||||
|
// Replace [0,4) which ends on the \n of "foo\r\n" (index 4, preceded by \r@3).
|
||||||
|
got, err := spliceContent(EditReplace, []byte("foo\r\nbar\r\n"), 0, 4, "X", "\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("splice: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "X\r\nbar\r\n" {
|
||||||
|
t.Fatalf("pullback failed: got %q, want %q", got, "X\r\nbar\r\n")
|
||||||
|
}
|
||||||
|
// No \n may appear without a preceding \r.
|
||||||
|
for i, b := range got {
|
||||||
|
if b == '\n' && (i == 0 || got[i-1] != '\r') {
|
||||||
|
t.Errorf("bare LF at index %d in %q", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+19
-1
@@ -304,7 +304,12 @@ func (c *Client) send(msg interface{}) error {
|
|||||||
// On exit (for any reason), it drains all pending Call waiters with a
|
// On exit (for any reason), it drains all pending Call waiters with a
|
||||||
// synthetic error so that goroutines blocked in Call are unblocked.
|
// synthetic error so that goroutines blocked in Call are unblocked.
|
||||||
func (c *Client) readLoop() {
|
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.drainPending()
|
||||||
|
defer c.setRunning(false)
|
||||||
|
|
||||||
reader := bufio.NewReader(c.stdout)
|
reader := bufio.NewReader(c.stdout)
|
||||||
|
|
||||||
@@ -380,13 +385,19 @@ func (c *Client) drainPending() {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
for id, ch := range c.pending {
|
for id, ch := range c.pending {
|
||||||
ch <- &Response{
|
// Non-blocking, matching the dispatch path: the channel is buffered(1)
|
||||||
|
// and read once, but a duplicate-id race could have already filled it —
|
||||||
|
// never block here while holding c.mu.
|
||||||
|
select {
|
||||||
|
case ch <- &Response{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: id,
|
ID: id,
|
||||||
Error: &ResponseError{
|
Error: &ResponseError{
|
||||||
Code: -32603, // InternalError
|
Code: -32603, // InternalError
|
||||||
Message: "LSP client readLoop terminated",
|
Message: "LSP client readLoop terminated",
|
||||||
},
|
},
|
||||||
|
}:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
delete(c.pending, id)
|
delete(c.pending, id)
|
||||||
}
|
}
|
||||||
@@ -398,3 +409,10 @@ func (c *Client) IsRunning() bool {
|
|||||||
defer c.runningMu.RUnlock()
|
defer c.runningMu.RUnlock()
|
||||||
return c.running
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,48 @@
|
|||||||
package lsp
|
package lsp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
|
||||||
// must keep recent bytes and never grow past StderrCaptureMax.
|
// must keep recent bytes and never grow past StderrCaptureMax.
|
||||||
func TestClientStderrCapture(t *testing.T) {
|
func TestClientStderrCapture(t *testing.T) {
|
||||||
|
|||||||
+14
-2
@@ -128,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
|||||||
srv, exists := m.servers[lang]
|
srv, exists := m.servers[lang]
|
||||||
m.mu.RUnlock()
|
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
|
// Update lastUsed with server's own lock to avoid race condition
|
||||||
srv.mu.Lock()
|
srv.mu.Lock()
|
||||||
srv.lastUsed = time.Now()
|
srv.lastUsed = time.Now()
|
||||||
@@ -141,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Double-check after acquiring write lock
|
// 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.mu.Lock()
|
||||||
srv.lastUsed = time.Now()
|
srv.lastUsed = time.Now()
|
||||||
srv.mu.Unlock()
|
srv.mu.Unlock()
|
||||||
return srv, nil
|
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
|
// Check if server config exists
|
||||||
config, ok := DefaultServerConfigs[lang]
|
config, ok := DefaultServerConfigs[lang]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
|
|||||||
return nil
|
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
|
// Convert to 0-indexed
|
||||||
point := sitter.Point{
|
point := sitter.Point{
|
||||||
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
|
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,70 +4,45 @@ package parser
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
|
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
|
||||||
type YAMLNode struct {
|
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
|
||||||
*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
|
|
||||||
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||||
// Check file size
|
|
||||||
if len(content) > maxFileSize {
|
if len(content) > maxFileSize {
|
||||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML
|
|
||||||
var root yaml.Node
|
var root yaml.Node
|
||||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||||
return nil, errors.NewParseError("yaml", filename, err)
|
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{
|
return &ParseResult{
|
||||||
Tree: nil, // YAML uses yaml.Node instead
|
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
|
||||||
Language: protocol.LangYAML,
|
Language: protocol.LangYAML,
|
||||||
Errors: syntaxErrors,
|
Errors: extractYAMLErrors(),
|
||||||
Content: content,
|
Content: content,
|
||||||
}, nil
|
}, 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) {
|
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
|
||||||
// Check file size
|
|
||||||
if len(content) > maxFileSize {
|
if len(content) > maxFileSize {
|
||||||
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON to validate syntax
|
|
||||||
var jsonData any
|
var jsonData any
|
||||||
if err := json.Unmarshal(content, &jsonData); err != nil {
|
if err := json.Unmarshal(content, &jsonData); err != nil {
|
||||||
return nil, errors.NewParseError("json", filename, err)
|
return nil, errors.NewParseError("json", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON parsing succeeded, no syntax errors
|
|
||||||
return &ParseResult{
|
return &ParseResult{
|
||||||
Tree: nil, // JSON uses native Go structures
|
Tree: nil, // JSON uses native Go structures
|
||||||
Language: protocol.LangJSON,
|
Language: protocol.LangJSON,
|
||||||
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
|
|||||||
}, nil
|
}, 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 {
|
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{}
|
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"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"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)
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -509,11 +509,15 @@ func formatResults(results []MatchResult, maxResults int, format string, offset
|
|||||||
case "compact":
|
case "compact":
|
||||||
for i := 0; i < renderCount; i++ {
|
for i := 0; i < renderCount; i++ {
|
||||||
r := results[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"
|
nodeType := "unknown"
|
||||||
if r.Node != nil {
|
if r.Node != nil {
|
||||||
nodeType = r.Node.Type()
|
nodeType = r.Node.Type()
|
||||||
}
|
}
|
||||||
firstLine := firstLineOf(r.Text, 80)
|
|
||||||
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
|
|||||||
WithRemediation("Provide paths within the workspace root")
|
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.
|
// parseOutput parses ripgrep JSON output.
|
||||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||||
results := &SearchResults{
|
results := &SearchResults{
|
||||||
@@ -288,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
|
|||||||
continue
|
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
|
// Check max results
|
||||||
if maxResults > 0 && len(results.Results) >= maxResults {
|
if maxResults > 0 && len(results.Results) >= maxResults {
|
||||||
results.Truncated = true
|
results.Truncated = true
|
||||||
|
|||||||
@@ -402,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
|
|||||||
t.Error("expected error for empty pattern")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,6 +213,20 @@ func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRelPath verifies workspace-relative display paths (token saving) with a
|
||||||
|
// safe fallback to the original path for anything outside the root.
|
||||||
|
func TestRelPath(t *testing.T) {
|
||||||
|
srv, tmpDir := newFeaturesServer(t)
|
||||||
|
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
|
||||||
|
t.Errorf("in-workspace path should be relativized, got %q", got)
|
||||||
|
}
|
||||||
|
// Outside the workspace root → returned unchanged (never a ../ escape string).
|
||||||
|
outside := "/definitely/outside/x.go"
|
||||||
|
if got := srv.relPath(outside); got != outside {
|
||||||
|
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||||
|
|
||||||
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
|
|||||||
prefsMaxResults = sp.DefaultMaxResults
|
prefsMaxResults = sp.DefaultMaxResults
|
||||||
prefsFormat = sp.ASTQueryFormat
|
prefsFormat = sp.ASTQueryFormat
|
||||||
}
|
}
|
||||||
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
|
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 50)
|
||||||
|
|
||||||
p.format = request.GetString("format", "")
|
p.format = request.GetString("format", "")
|
||||||
if p.format == "" {
|
if p.format == "" {
|
||||||
@@ -263,15 +263,7 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Get relative path
|
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
|
||||||
relPath := path
|
|
||||||
if absPath, err := filepath.Abs(path); err == nil {
|
|
||||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
|
||||||
relPath = rel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
|
|
||||||
sb.WriteString("Symbols:\n")
|
sb.WriteString("Symbols:\n")
|
||||||
|
|
||||||
for _, sym := range symbols {
|
for _, sym := range symbols {
|
||||||
@@ -282,6 +274,18 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
|
|||||||
return sb.String()
|
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.
|
// symbolKindIcon returns an icon/prefix for a symbol kind.
|
||||||
func symbolKindIcon(kind protocol.SymbolKind) string {
|
func symbolKindIcon(kind protocol.SymbolKind) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
|||||||
fileTypes := request.GetStringSlice("file_types", nil)
|
fileTypes := request.GetStringSlice("file_types", nil)
|
||||||
ignoreCase := request.GetBool("ignore_case", false)
|
ignoreCase := request.GetBool("ignore_case", false)
|
||||||
regex := request.GetBool("regex", true)
|
regex := request.GetBool("regex", true)
|
||||||
contextLines := request.GetInt("context_lines", 2)
|
contextLines := request.GetInt("context_lines", 0)
|
||||||
|
|
||||||
// Consult session prefs for max_results and cluster when not explicitly supplied.
|
// Consult session prefs for max_results and cluster when not explicitly supplied.
|
||||||
prefs := s.sessionPrefs.Load()
|
prefs := s.sessionPrefs.Load()
|
||||||
@@ -326,9 +326,13 @@ type lineNumberOpts struct {
|
|||||||
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
|
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
|
||||||
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
|
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
|
||||||
opts := lineNumberOpts{
|
opts := lineNumberOpts{
|
||||||
noLineNumbers: request.GetBool("no_line_numbers", false),
|
noLineNumbers: request.GetBool("no_line_numbers", false),
|
||||||
lineInterval: request.GetInt("line_number_interval", 1),
|
lineInterval: request.GetInt("line_number_interval", 1),
|
||||||
compactLineNums: request.GetBool("compact_line_numbers", false),
|
// 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.
|
// Apply session line_numbers pref when no explicit per-call override was supplied.
|
||||||
|
|||||||
@@ -589,19 +589,23 @@ func TestCompactLineNumbers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompactLineNumbersOffByDefault(t *testing.T) {
|
func TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
srv := newTestServer(t, tmpDir)
|
srv := newTestServer(t, tmpDir)
|
||||||
|
|
||||||
content := "line one\nline two\n"
|
content := "line one\nline two\n"
|
||||||
f := writeFile(t, tmpDir, "test.txt", content)
|
f := writeFile(t, tmpDir, "test.txt", content)
|
||||||
|
|
||||||
// Default: no compact_line_numbers
|
// Default is now the compact prefix (token-saving): "1│line one".
|
||||||
out := callRead(t, srv, map[string]interface{}{"path": f})
|
def := callRead(t, srv, map[string]interface{}{"path": f})
|
||||||
|
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
|
||||||
|
t.Errorf("default should use compact prefix, got:\n%s", def)
|
||||||
|
}
|
||||||
|
|
||||||
// Should have padded format
|
// Explicit opt-out restores the padded alignment.
|
||||||
if !strings.Contains(out, " 1│ line one") {
|
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
|
||||||
t.Errorf("default should have padded format, got:\n%s", out)
|
if !strings.Contains(padded, " 1│ line one") {
|
||||||
|
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,9 +683,9 @@ func Hello() {
|
|||||||
if !strings.Contains(out, `println("hello")`) {
|
if !strings.Contains(out, `println("hello")`) {
|
||||||
t.Errorf("full mode missing body, got:\n%s", out)
|
t.Errorf("full mode missing body, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// Padded line numbers
|
// Default line numbers use the compact prefix.
|
||||||
if !strings.Contains(out, " 1│ package main") {
|
if !strings.Contains(out, "1│package main") {
|
||||||
t.Errorf("default should have padded line numbers, got:\n%s", out)
|
t.Errorf("default should have compact line numbers, got:\n%s", out)
|
||||||
}
|
}
|
||||||
// etag present
|
// etag present
|
||||||
if !strings.Contains(out, "[etag:") {
|
if !strings.Contains(out, "[etag:") {
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
|
|||||||
|
|
||||||
case "references":
|
case "references":
|
||||||
includeDecl := request.GetBool("include_declaration", true)
|
includeDecl := request.GetBool("include_declaration", true)
|
||||||
// compact: explicit call-time > session compact_refs pref > false
|
// compact: explicit call-time > session compact_refs pref > true.
|
||||||
|
// Compact (one line per file) is the default — it preserves every
|
||||||
|
// line:col pair while cutting ~one line per reference for the LLM consumer.
|
||||||
var prefsCompact *bool
|
var prefsCompact *bool
|
||||||
if sp := s.sessionPrefs.Load(); sp != nil {
|
if sp := s.sessionPrefs.Load(); sp != nil {
|
||||||
prefsCompact = sp.CompactRefs
|
prefsCompact = sp.CompactRefs
|
||||||
}
|
}
|
||||||
compact := effectiveBool(request, "compact", prefsCompact, false)
|
compact := effectiveBool(request, "compact", prefsCompact, true)
|
||||||
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
|
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -181,7 +183,7 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
|
|||||||
|
|
||||||
for _, loc := range locations {
|
for _, loc := range locations {
|
||||||
filePath := lsp.URIToFile(loc.URI)
|
filePath := lsp.URIToFile(loc.URI)
|
||||||
fmt.Fprintf(&output, "**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
||||||
|
|
||||||
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||||
if preview != "" {
|
if preview != "" {
|
||||||
@@ -210,7 +212,9 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
|
|||||||
fileGroups := make(map[string][]lsp.Location)
|
fileGroups := make(map[string][]lsp.Location)
|
||||||
fileOrder := make([]string, 0)
|
fileOrder := make([]string, 0)
|
||||||
for _, loc := range locations {
|
for _, loc := range locations {
|
||||||
filePath := lsp.URIToFile(loc.URI)
|
// Relativize for display; references are not re-read so the absolute
|
||||||
|
// path isn't needed downstream.
|
||||||
|
filePath := s.relPath(lsp.URIToFile(loc.URI))
|
||||||
if _, seen := fileGroups[filePath]; !seen {
|
if _, seen := fileGroups[filePath]; !seen {
|
||||||
fileOrder = append(fileOrder, filePath)
|
fileOrder = append(fileOrder, filePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const helpFileSearch = "# file_search — flags and examples\n\n" +
|
|||||||
"| `cluster=true` | Coalesce consecutive match lines into ranges (`L12-14│ text`). Drops context lines for density. |\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" +
|
"| `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" +
|
"| `max_results` | Page size for pagination. Re-run with `cursor` to get next page. |\n" +
|
||||||
"| `context_lines` | Number of context lines around matches (default: 2). |\n" +
|
"| `context_lines` | Number of context lines around matches (default: 0). |\n" +
|
||||||
"| `ignore_case` | Case-insensitive search. |\n" +
|
"| `ignore_case` | Case-insensitive search. |\n" +
|
||||||
"| `regex` | Treat pattern as regex (default: true). |\n" +
|
"| `regex` | Treat pattern as regex (default: true). |\n" +
|
||||||
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
|
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
|
||||||
@@ -77,7 +77,7 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
|
|||||||
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\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" +
|
"| `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" +
|
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
|
||||||
"| `max_results` | Page size (default: 100). |\n" +
|
"| `max_results` | Page size (default: 50). |\n" +
|
||||||
"| `name_exact` | Exact symbol name to match. |\n" +
|
"| `name_exact` | Exact symbol name to match. |\n" +
|
||||||
"| `name_matches` | Regex pattern to filter by name. |\n" +
|
"| `name_matches` | Regex pattern to filter by name. |\n" +
|
||||||
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
|
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
parser: parserRegistry,
|
parser: parserRegistry,
|
||||||
matcher: query.NewMatcher(parserRegistry),
|
matcher: query.NewMatcher(parserRegistry),
|
||||||
editor: edit.NewEngine(parserRegistry),
|
editor: edit.NewEngineWithValidator(parserRegistry, cfg.IsPathAllowed),
|
||||||
readSem: make(chan struct{}, MaxConcurrentReads),
|
readSem: make(chan struct{}, MaxConcurrentReads),
|
||||||
querySem: make(chan struct{}, MaxConcurrentQueries),
|
querySem: make(chan struct{}, MaxConcurrentQueries),
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ func (s *Server) registerTools() {
|
|||||||
mcp.Description("Treat pattern as regex (default: true)"),
|
mcp.Description("Treat pattern as regex (default: true)"),
|
||||||
),
|
),
|
||||||
mcp.WithNumber("context_lines",
|
mcp.WithNumber("context_lines",
|
||||||
mcp.Description("Number of context lines around matches (default: 2)"),
|
mcp.Description("Number of context lines around matches (default: 0)"),
|
||||||
),
|
),
|
||||||
mcp.WithNumber("max_results",
|
mcp.WithNumber("max_results",
|
||||||
mcp.Description("Maximum number of results to return (page size for pagination)"),
|
mcp.Description("Maximum number of results to return (page size for pagination)"),
|
||||||
@@ -206,7 +206,7 @@ func (s *Server) registerTools() {
|
|||||||
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
|
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
|
||||||
),
|
),
|
||||||
mcp.WithBoolean("compact_line_numbers",
|
mcp.WithBoolean("compact_line_numbers",
|
||||||
mcp.Description("Use compact line prefix '12\u2502' instead of ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default off."),
|
mcp.Description("Use compact line prefix '12\u2502' instead of padded ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default true; set false to restore padded alignment."),
|
||||||
),
|
),
|
||||||
mcp.WithBoolean("collapse_blank_lines",
|
mcp.WithBoolean("collapse_blank_lines",
|
||||||
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
|
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
|
||||||
@@ -252,7 +252,7 @@ func (s *Server) registerTools() {
|
|||||||
mcp.WithStringItems(),
|
mcp.WithStringItems(),
|
||||||
),
|
),
|
||||||
mcp.WithNumber("max_results",
|
mcp.WithNumber("max_results",
|
||||||
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
|
mcp.Description("Maximum number of results to return (default: 50, page size for pagination)"),
|
||||||
),
|
),
|
||||||
mcp.WithString("format",
|
mcp.WithString("format",
|
||||||
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
|
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
|
||||||
@@ -299,7 +299,7 @@ func (s *Server) registerTools() {
|
|||||||
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
|
||||||
),
|
),
|
||||||
mcp.WithBoolean("compact",
|
mcp.WithBoolean("compact",
|
||||||
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: false."),
|
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: true; set false for one line per reference."),
|
||||||
),
|
),
|
||||||
mcp.WithBoolean("verbose",
|
mcp.WithBoolean("verbose",
|
||||||
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
|
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
|
|||||||
t.Fatalf("handleFileRead: %v", err)
|
t.Fatalf("handleFileRead: %v", err)
|
||||||
}
|
}
|
||||||
text := result.Content[0].(mcp.TextContent).Text
|
text := result.Content[0].(mcp.TextContent).Text
|
||||||
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
|
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
|
||||||
if strings.Contains(text, " 1│") {
|
if strings.Contains(text, " 1│") {
|
||||||
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
|
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
|
|||||||
req.Params.Arguments = map[string]interface{}{"path": testFile}
|
req.Params.Arguments = map[string]interface{}{"path": testFile}
|
||||||
result, _ := srv.handleFileRead(ctx, req)
|
result, _ := srv.handleFileRead(ctx, req)
|
||||||
text := result.Content[0].(mcp.TextContent).Text
|
text := result.Content[0].(mcp.TextContent).Text
|
||||||
// Standard padded prefix is " 1│ "; compact is "1│".
|
// Padded prefix is " 1│ "; compact is "1│".
|
||||||
if strings.Contains(text, " 1\u2502") {
|
if strings.Contains(text, " 1\u2502") {
|
||||||
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
|
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user