Compare commits

...
6 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub aa7d884b0c Update go.mod and go.sum (#30) 2026-06-17 05:32:41 +01:00
lukaszraczyloandGitHub 0fe678cd60 Update go.mod and go.sum (#29) 2026-06-09 05:26:55 +01:00
lukaszraczyloandGitHub 70484d20c3 Update go.mod and go.sum (#28) 2026-05-29 05:31:00 +01:00
lukaszraczylo 6e0c5247c1 ci: replace retired macos-13 runner with macos-15-intel for darwin_amd64
macos-13 Intel runner image retired by GitHub on 2025-12-08; darwin_amd64
build job queued indefinitely with no available runner. Switch to the
standard macos-15-intel label (native x86_64, free for public repos,
supported until Aug 2027). CGO required by go-tree-sitter, so a native
Intel runner is needed.
2026-05-29 01:43:22 +01:00
lukaszraczylo 7774dc29d1 fix(release): partition CGO split builds by target to stop cross-arch failure
GoReleaser Pro --split defaults to partitioning by GOOS only, so each
linux matrix runner built both linux/amd64 and linux/arm64. With
CGO_ENABLED=1 (go-tree-sitter), the native gcc cannot assemble the other
arch's CGO runtime (gcc_arm64.S: no such instruction), failing the build.

- .goreleaser.yaml: set partial.by=target (partition by GOOS+GOARCH)
- workflow-prepare.sh: export GGOOS/GGOARCH from the matrix TARGET_* vars
  so each native runner builds exactly its own target

GGOOS/GGOARCH are filter-only and do not bleed into before-hooks, unlike
GOOS/GOARCH.
2026-05-29 00:57:29 +01:00
lukaszraczylo 9af2801b1b refactor(edit): remove auto-indentation and add line-ending normalization
- [x] Remove auto-indentation from text mode edits (caller controls whitespace)
- [x] Add line-ending detection and normalization for both AST and text modes
- [x] Share edit logic via new `spliceContent` function for both modes
- [x] Fix diff to emit "No newline at end of file" markers
- [x] Fix diff to strip raw CR from CRLF file output
- [x] Remove double-unescape of backslash sequences in new_content
- [x] Fix countDiffLines to be hunk-aware (correctly count lines starting with +/-)
- [x] Fix block-comment stripping to remove standalone lines cleanly
- [x] Fix Python license header stripping to preserve separator blank lines
2026-05-29 00:17:36 +01:00
14 changed files with 646 additions and 346 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
rolling-release-tag: "v1" rolling-release-tag: "v1"
platforms: >- platforms: >-
[ [
{"os":"macos-13","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"}, {"os":"macos-15-intel","goos":"darwin","goarch":"amd64","platform":"darwin_amd64"},
{"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"}, {"os":"macos-latest","goos":"darwin","goarch":"arm64","platform":"darwin_arm64"},
{"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"}, {"os":"ubuntu-latest","goos":"linux","goarch":"amd64","platform":"linux_amd64"},
{"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"}, {"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64","platform":"linux_arm64"},
+9
View File
@@ -5,6 +5,15 @@ version: 2
project_name: mcp-filepuff 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: before:
hooks: hooks:
- go mod tidy - go mod tidy
+4 -2
View File
@@ -6,15 +6,17 @@ 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.10.6
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.48.0 github.com/mark3labs/mcp-go v0.55.0
github.com/sergi/go-diff v1.4.0 github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/google/jsonschema-go v0.4.2 // 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/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
) )
+16 -9
View File
@@ -3,14 +3,16 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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 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.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 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.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -22,12 +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.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 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=
@@ -36,13 +40,16 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/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/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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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 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-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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+183 -208
View File
@@ -481,98 +481,104 @@ func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte
return true return true
} }
// applyEdit applies the edit operation to the content. // applyEdit applies an AST-mode edit. new_content is inserted verbatim — the caller
// AST mode uses exact byte positions — new_content is inserted verbatim without auto-indentation. // 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) { func (e *Engine) applyEdit(edit *ASTEdit, node *sitter.Node, content []byte) ([]byte, error) {
startByte := node.StartByte() eol := detectLineEnding(content)
endByte := node.EndByte() newContent := normalizeLineEndings(edit.NewContent, eol)
return spliceContent(edit.Operation, content, int(node.StartByte()), int(node.EndByte()), newContent, eol)
}
newContent := edit.NewContent // 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 var result []byte
switch op {
switch edit.Operation {
case EditReplace: case EditReplace:
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, []byte(newContent)...) result = append(result, newContent...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't, // Restore a line terminator if the replaced range ended with one but the
// re-add it to prevent line merging // replacement does not, to prevent merging with the following line.
if endByte > startByte && content[endByte-1] == '\n' && !strings.HasSuffix(newContent, "\n") { if end > start && content[end-1] == '\n' && !endsWithNewline(newContent) {
result = append(result, '\n') result = append(result, eol...)
} }
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
case EditInsertBefore: case EditInsertBefore:
insertion := newContent insertion := newContent
if !strings.HasSuffix(insertion, "\n") { if !endsWithNewline(insertion) {
insertion += "\n" insertion += eol
} }
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, []byte(insertion)...) result = append(result, insertion...)
result = append(result, content[startByte:]...) result = append(result, content[start:]...)
case EditInsertAfter: case EditInsertAfter:
insertion := newContent insertion := newContent
// Ensure separation from preceding content // Separate from preceding content.
if endByte > 0 && content[endByte-1] != '\n' && !strings.HasPrefix(insertion, "\n") { if end > 0 && content[end-1] != '\n' && !startsWithNewline(insertion) {
insertion = "\n" + insertion insertion = eol + insertion
} }
// Ensure separation from following content // Separate from following content.
if !strings.HasSuffix(insertion, "\n") && endByte < uint32(len(content)) && content[endByte] != '\n' { if !endsWithNewline(insertion) && end < len(content) && content[end] != '\n' {
insertion += "\n" insertion += eol
} }
result = append(result, content[:endByte]...) result = append(result, content[:end]...)
result = append(result, []byte(insertion)...) result = append(result, insertion...)
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
case EditDelete: case EditDelete:
result = append(result, content[:startByte]...) result = append(result, content[:start]...)
result = append(result, content[endByte:]...) result = append(result, content[end:]...)
default: 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 return result, nil
} }
// detectIndentation detects the indentation at a given byte position. // noNewlineMarker is the git-style annotation emitted after a diff line whose source
func detectIndentation(content []byte, bytePos int) string { // version has no trailing newline.
// Find the start of the line const noNewlineMarker = "\\ No newline at end of file\n"
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()
}
// 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")
}
// diffLine represents a single line in the diff with its type and content. // diffLine represents a single line in the diff with its type and content.
type diffLine struct { type diffLine struct {
@@ -582,32 +588,56 @@ type diffLine struct {
newN int // 1-based line number in modified (0 if delete) newN int // 1-based line number in modified (0 if delete)
} }
// 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. // generateDiff creates a unified diff between original and modified content.
// Uses line-level Myers diff algorithm and outputs a proper unified diff // Uses a line-level Myers diff and outputs a unified diff with 3 lines of context
// with context lines (3 before/after each change, merging close hunks). // before/after each change, merging close hunks.
func (e *Engine) generateDiff(original, modified, filename string) string { func (e *Engine) generateDiff(original, modified, filename string) string {
dmp := e.dmp dmp := e.dmp
// Use line-level diffing: encode each line as a single character, // Line-level diffing: encode each line as a single rune, diff the encoded strings,
// diff the encoded strings, then decode back to real lines. // then decode back to real lines.
chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified) chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified)
diffs := dmp.DiffMain(chars1, chars2, false) diffs := dmp.DiffCharsToLines(dmp.DiffMain(chars1, chars2, false), lineArray)
diffs = dmp.DiffCharsToLines(diffs, lineArray)
// Cleanup for readability
diffs = dmp.DiffCleanupSemantic(diffs) diffs = dmp.DiffCleanupSemantic(diffs)
// Flatten diffs into individual lines with line numbers // Track whether each version lacks a final newline, so the diff is annotated
var lines []diffLine // git-style ("\ No newline at end of file") instead of implying a phantom one.
oldLine := 1 origNoEOL := len(original) > 0 && !strings.HasSuffix(original, "\n")
newLine := 1 modNoEOL := len(modified) > 0 && !strings.HasSuffix(modified, "\n")
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 _, d := range diffs {
rawLines := strings.SplitAfter(d.Text, "\n") for _, raw := range strings.SplitAfter(d.Text, "\n") {
for _, raw := range rawLines {
if raw == "" { if raw == "" {
continue continue
} }
text := strings.TrimSuffix(raw, "\n") // 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")
switch d.Type { switch d.Type {
case diffmatchpatch.DiffEqual: case diffmatchpatch.DiffEqual:
lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine})
@@ -622,97 +652,88 @@ func (e *Engine) generateDiff(original, modified, filename string) string {
} }
} }
} }
return lines, oldLine - 1, newLine - 1
}
// Identify indices of changed lines // 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 const contextSize = 3
var changedIndices []int
for i, l := range lines {
if l.op != diffmatchpatch.DiffEqual {
changedIndices = append(changedIndices, i)
}
}
if len(changedIndices) == 0 {
return "" // no changes
}
// Build inclusion ranges: for each changed line, include contextSize lines before/after.
// Merge overlapping or adjacent ranges (gap <= 2*contextSize = 6 context lines).
type indexRange struct{ start, end int } // inclusive
var ranges []indexRange var ranges []indexRange
for _, ci := range changedIndices { for i, l := range lines {
rStart := ci - contextSize if l.op == diffmatchpatch.DiffEqual {
if rStart < 0 { continue
rStart = 0
}
rEnd := ci + contextSize
if rEnd >= len(lines) {
rEnd = len(lines) - 1
} }
rStart := max(i-contextSize, 0)
rEnd := min(i+contextSize, len(lines)-1)
if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 { if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 {
// Merge with previous range ranges[len(ranges)-1].end = rEnd // merge with previous
ranges[len(ranges)-1].end = rEnd
} else { } else {
ranges = append(ranges, indexRange{rStart, rEnd}) ranges = append(ranges, indexRange{rStart, rEnd})
} }
} }
return ranges
}
// Emit unified diff // hunkBounds computes the unified-diff hunk header line numbers and counts for
var buf bytes.Buffer // lines[start:end+1]. newStart/oldStart for one-sided lines are approximate.
buf.WriteString(fmt.Sprintf("--- %s\n", filename)) func hunkBounds(lines []diffLine, start, end int) (oldStart, oldCount, newStart, newCount int) {
buf.WriteString(fmt.Sprintf("+++ %s\n", filename)) for i := start; i <= end; i++ {
l := lines[i]
for _, r := range ranges { switch l.op {
// Determine hunk header line numbers case diffmatchpatch.DiffEqual:
var oldStart, oldCount, newStart, newCount int if oldCount == 0 {
for i := r.start; i <= r.end; i++ { oldStart = l.oldN
l := lines[i]
switch l.op {
case diffmatchpatch.DiffEqual:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
newStart = l.newN
}
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
// Set newStart from context or next available
newStart = l.oldN // approximate
}
oldCount++
case diffmatchpatch.DiffInsert:
if newCount == 0 {
newStart = l.newN
}
if oldCount == 0 {
oldStart = l.newN // approximate
}
newCount++
} }
if newCount == 0 {
newStart = l.newN
}
oldCount++
newCount++
case diffmatchpatch.DiffDelete:
if oldCount == 0 {
oldStart = l.oldN
}
if newCount == 0 {
newStart = l.oldN // approximate
}
oldCount++
case diffmatchpatch.DiffInsert:
if newCount == 0 {
newStart = l.newN
}
if oldCount == 0 {
oldStart = l.newN // approximate
}
newCount++
} }
}
return
}
buf.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)) // 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.
for i := r.start; i <= r.end; i++ { func writeDiffBody(buf *bytes.Buffer, lines []diffLine, start, end int, origNoEOL, modNoEOL bool, maxOldN, maxNewN int) {
l := lines[i] for i := start; i <= end; i++ {
switch l.op { l := lines[i]
case diffmatchpatch.DiffEqual: switch l.op {
buf.WriteString(fmt.Sprintf(" %s\n", l.text)) case diffmatchpatch.DiffEqual:
case diffmatchpatch.DiffDelete: fmt.Fprintf(buf, " %s\n", l.text)
buf.WriteString(fmt.Sprintf("-%s\n", l.text)) if (origNoEOL && l.oldN == maxOldN) || (modNoEOL && l.newN == maxNewN) {
case diffmatchpatch.DiffInsert: buf.WriteString(noNewlineMarker)
buf.WriteString(fmt.Sprintf("+%s\n", l.text)) }
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)
} }
} }
} }
return buf.String()
} }
// resolveTextSelector finds the byte range for a text-based selection. // resolveTextSelector finds the byte range for a text-based selection.
@@ -831,57 +852,11 @@ func (e *Engine) findLineRange(content []byte, lineStart, lineEnd int) (start, e
return start, end, nil 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) { func (e *Engine) applyTextEditOperation(op EditOperation, content []byte, start, end int, newContent string) ([]byte, error) {
// Detect indentation at the selection point eol := detectLineEnding(content)
indentation := detectIndentation(content, start) return spliceContent(op, content, start, end, normalizeLineEndings(newContent, eol), eol)
indentedContent := indentContent(newContent, indentation)
var result []byte
switch op {
case EditReplace:
result = append(result, content[:start]...)
result = append(result, []byte(indentedContent)...)
// Preserve trailing newline: if selection ended with \n but replacement doesn't,
// re-add it to prevent line merging
if end > start && content[end-1] == '\n' && !strings.HasSuffix(indentedContent, "\n") {
result = append(result, '\n')
}
result = append(result, content[end:]...)
case EditInsertBefore:
insertion := indentedContent
if !strings.HasSuffix(insertion, "\n") {
insertion += "\n"
}
result = append(result, content[:start]...)
result = append(result, []byte(insertion)...)
result = append(result, content[start:]...)
case EditInsertAfter:
insertion := indentedContent
// Ensure separation from preceding content
if end > 0 && content[end-1] != '\n' && !strings.HasPrefix(insertion, "\n") {
insertion = "\n" + insertion
}
// Ensure separation from following content
if !strings.HasSuffix(insertion, "\n") && end < len(content) && content[end] != '\n' {
insertion += "\n"
}
result = append(result, content[:end]...)
result = append(result, []byte(insertion)...)
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
} }
// truncateString truncates a string to maxLen with ellipsis. // truncateString truncates a string to maxLen with ellipsis.
-37
View File
@@ -353,43 +353,6 @@ func Hello() {
} }
} }
func TestDetectIndentation(t *testing.T) {
tests := []struct {
name string
content string
want string
pos int
}{
{
name: "no indent",
content: "func main() {}",
pos: 0,
want: "",
},
{
name: "tab indent",
content: "func main() {\n\tprintln(\"hello\")\n}",
pos: 15,
want: "\t",
},
{
name: "space indent",
content: "func main() {\n println(\"hello\")\n}",
pos: 18,
want: " ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectIndentation([]byte(tt.content), tt.pos)
if got != tt.want {
t.Errorf("detectIndentation() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateDiff(t *testing.T) { func TestGenerateDiff(t *testing.T) {
original := "line1\nline2\nline3" original := "line1\nline2\nline3"
modified := "line1\nmodified\nline3" modified := "line1\nmodified\nline3"
+172
View File
@@ -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)
}
}
+78 -30
View File
@@ -81,22 +81,21 @@ func stripLicense(content string) (string, bool) {
} }
} }
// Python/hash-style leading comment block // 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, "#") { if strings.HasPrefix(trimmed, "#") {
lines := strings.Split(trimmed, "\n") lines := strings.Split(trimmed, "\n")
var commentLines []string var commentLines, rest []string
var rest []string
inComment := true
for i, l := range lines { for i, l := range lines {
if inComment && (strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "") { if strings.HasPrefix(l, "#") {
commentLines = append(commentLines, l) commentLines = append(commentLines, l)
} else { continue
rest = lines[i:]
break
} }
rest = lines[i:]
break
} }
block := strings.Join(commentLines, "\n") lower := strings.ToLower(strings.Join(commentLines, "\n"))
lower := strings.ToLower(block)
if strings.Contains(lower, "copyright") || if strings.Contains(lower, "copyright") ||
strings.Contains(lower, "license") || strings.Contains(lower, "license") ||
strings.Contains(lower, "spdx-license-identifier") { strings.Contains(lower, "spdx-license-identifier") {
@@ -240,60 +239,109 @@ func stripBlockComments(content string, lang protocol.Language) (string, bool) {
return stripCStyleBlockComments(content) return stripCStyleBlockComments(content)
} }
// stripCStyleBlockComments removes /* ... */ from 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) { func stripCStyleBlockComments(content string) (string, bool) {
removed := false removed := false
var sb strings.Builder out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0 i := 0
for i < len(content) { for i < len(content) {
if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' { if i+1 < len(content) && content[i] == '/' && content[i+1] == '*' {
// find closing */ if end := strings.Index(content[i+2:], "*/"); end >= 0 {
end := strings.Index(content[i+2:], "*/")
if end >= 0 {
removed = true removed = true
// advance past */ standalone := !lineHasNonSpace
i = i + 2 + end + 2 i = i + 2 + end + 2 // advance past closing */
// consume trailing newline if standalone {
if i < len(content) && content[i] == '\n' { out = trimTrailingLineWhitespace(out)
i++ i = skipLineTail(content, i)
lineHasNonSpace = false
} }
continue continue
} }
} }
sb.WriteByte(content[i]) 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++ i++
} }
if !removed { if !removed {
return content, false return content, false
} }
return sb.String(), true return string(out), true
} }
// stripPythonDocstrings removes triple-quoted strings (""" and ”'). // 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) { func stripPythonDocstrings(content string) (string, bool) {
removed := false removed := false
var sb strings.Builder out := make([]byte, 0, len(content))
lineHasNonSpace := false
i := 0 i := 0
for i < len(content) { for i < len(content) {
if i+2 < len(content) { if i+2 < len(content) {
triple := content[i : i+3] triple := content[i : i+3]
if triple == `"""` || triple == `'''` { if triple == `"""` || triple == `'''` {
end := strings.Index(content[i+3:], triple) if end := strings.Index(content[i+3:], triple); end >= 0 {
if end >= 0 {
removed = true removed = true
standalone := !lineHasNonSpace
i = i + 3 + end + 3 i = i + 3 + end + 3
if i < len(content) && content[i] == '\n' { if standalone {
i++ out = trimTrailingLineWhitespace(out)
i = skipLineTail(content, i)
lineHasNonSpace = false
} }
continue continue
} }
} }
} }
sb.WriteByte(content[i]) 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++ i++
} }
if !removed { if !removed {
return content, false return content, false
} }
return sb.String(), true return string(out), true
} }
+47
View File
@@ -0,0 +1,47 @@
package parser
import (
"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)
}
}
+18 -19
View File
@@ -11,16 +11,6 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
// unescapeNewlines converts literal \n, \t, \" sequences to actual characters.
// This handles cases where MCP clients send double-escaped JSON strings.
func unescapeNewlines(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\t", "\t")
s = strings.ReplaceAll(s, "\\\"", "\"")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}
// handleEditApply handles the edit_apply tool. // handleEditApply handles the edit_apply tool.
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.handleEdit(ctx, request) return s.handleEdit(ctx, request)
@@ -51,8 +41,12 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
return mcp.NewToolResultError("file is outside workspace root"), nil 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", "") newContent := request.GetString("new_content", "")
newContent = unescapeNewlines(newContent)
selectorName := request.GetString("selector_name", "") selectorName := request.GetString("selector_name", "")
@@ -114,21 +108,26 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
} }
} }
// countDiffLines counts added (+) and removed (-) lines in a unified diff string. // 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) { func countDiffLines(diff string) (added, removed int) {
inHunk := false
for _, line := range strings.Split(diff, "\n") { for _, line := range strings.Split(diff, "\n") {
if len(line) == 0 { if strings.HasPrefix(line, "@@") {
inHunk = true
continue
}
if !inHunk || line == "" {
continue continue
} }
switch line[0] { switch line[0] {
case '+': case '+':
if !strings.HasPrefix(line, "+++") { added++
added++
}
case '-': case '-':
if !strings.HasPrefix(line, "---") { removed++
removed++
}
} }
} }
return return
+95 -39
View File
@@ -1,47 +1,103 @@
package server package server
import "testing" import (
"context"
"os"
"strings"
"testing"
func TestUnescapeNewlines(t *testing.T) { "github.com/mark3labs/mcp-go/mcp"
tests := []struct { )
name string
input string
expected string
}{
{
name: "no escapes",
input: "func test() {\n\treturn true;\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash n",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal backslash t",
input: "func test() {\\n\\treturn true;\\n}",
expected: "func test() {\n\treturn true;\n}",
},
{
name: "literal quotes",
input: `func test() {\n\treturn \"true\";\n}`,
expected: "func test() {\n\treturn \"true\";\n}",
},
{ // callEdit invokes handleEditApply with the given args and returns the result.
name: "mixed content", func callEdit(t *testing.T, srv *Server, args map[string]any) *mcp.CallToolResult {
input: "line1\\nline2\\tindented\\nline3", t.Helper()
expected: "line1\nline2\tindented\nline3", 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)
} }
for _, tt := range tests { got, err := os.ReadFile(f)
t.Run(tt.name, func(t *testing.T) { if err != nil {
result := unescapeNewlines(tt.input) t.Fatalf("read back: %v", err)
if result != tt.expected { }
t.Errorf("unescapeNewlines(%q) = %q, expected %q", tt.input, result, tt.expected) want := newContent + "\n"
} if string(got) != want {
}) t.Fatalf("new_content not written verbatim (double-unescape regression).\nwant: %q\ngot: %q", want, string(got))
}
}
// 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))
} }
} }
+2
View File
@@ -137,6 +137,8 @@ const helpEditApply = "# edit_apply — flags and examples\n\n" +
"| `none` | Empty response (silent success) |\n\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" + "`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" + "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" + "## Selector types\n\n" +
"### AST-mode selectors (code files)\n" + "### AST-mode selectors (code files)\n" +
"- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" + "- `selector_kind` — AST node type (e.g. `function_declaration`, `class_declaration`)\n" +
+1 -1
View File
@@ -313,7 +313,7 @@ func (s *Server) registerTools() {
mcp.Description("Edit operation: replace, insert_before, insert_after, delete"), mcp.Description("Edit operation: replace, insert_before, insert_after, delete"),
), ),
mcp.WithString("new_content", mcp.WithString("new_content",
mcp.Description("New content (required for replace/insert operations)"), mcp.Description("New content (required for replace/insert operations). Inserted verbatim: send it as normal JSON (real newlines, quotes JSON-escaped as usual). The server does NOT re-interpret escape sequences, so backslash sequences in source code (e.g. \\n, \\t, \\\\ inside string literals, regexes, or Windows paths) are preserved exactly. Indentation is not auto-applied — include the leading whitespace you want. Line endings are normalized to the file's existing convention (LF or CRLF)."),
), ),
// AST-mode selectors (for code files) // AST-mode selectors (for code files)
mcp.WithString("selector_kind", mcp.WithString("selector_kind",
+20
View File
@@ -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