mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-11 04:47:58 +00:00
fix(parser): improve strip accuracy for imports and license
Three strip=imports/license correctness fixes (all heuristic, keyword/structure-gated):
- stripTSImports now tracks multi-line named imports (import {\n ... \n} from 'x') instead of dropping only the first line and leaving dangling members; side-effect imports without semicolons no longer trigger runaway consumption.
- stripGoImports recognizes all single-import spec forms: import _ "x", import . "x", and aliased import m "x" (previously only import "x"/backtick).
- stripLicense peels a leading shebang (#!) or Go build-constraint (//go:build, // +build) block and preserves it above the license, and now strips // line-comment license headers (not just /* */ and #). Adds tests for each.
This commit is contained in:
+103
-15
@@ -59,20 +59,60 @@ func StripContent(content string, flags []StripFlag, lang protocol.Language) Str
|
||||
return StripResult{Content: content, Stripped: stripped}
|
||||
}
|
||||
|
||||
// stripLicense removes a leading block comment that looks like a license header.
|
||||
// A comment qualifies if it contains "copyright", "license", or "spdx-license-identifier" (case-insensitive).
|
||||
// containsLicenseKeyword reports whether lowercased text looks like a license
|
||||
// header (contains "copyright", "license", or an SPDX identifier).
|
||||
func containsLicenseKeyword(lower string) bool {
|
||||
return strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier")
|
||||
}
|
||||
|
||||
// peelLicensePrefix splits off a leading run of lines that must be preserved
|
||||
// above a license header: a shebang (#!) on the first line and/or Go
|
||||
// build-constraint lines (//go:build, // +build). Returns the preserved prefix
|
||||
// (with terminators) and the remaining content. With no such lines, prefix is "".
|
||||
func peelLicensePrefix(content string) (prefix, rest string) {
|
||||
lines := strings.SplitAfter(content, "\n")
|
||||
n := 0
|
||||
for n < len(lines) {
|
||||
t := strings.TrimSpace(lines[n])
|
||||
isShebang := n == 0 && strings.HasPrefix(t, "#!")
|
||||
isBuild := strings.HasPrefix(t, "//go:build") || strings.HasPrefix(t, "// +build")
|
||||
if !isShebang && !isBuild {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return "", content
|
||||
}
|
||||
return strings.Join(lines[:n], ""), strings.Join(lines[n:], "")
|
||||
}
|
||||
|
||||
// stripLicense removes a leading license header comment. A comment qualifies if
|
||||
// it contains a license keyword (see containsLicenseKeyword). A leading shebang
|
||||
// or Go build-constraint block is preserved above the license rather than
|
||||
// consumed or blocking detection.
|
||||
func stripLicense(content string) (string, bool) {
|
||||
trimmed := strings.TrimLeft(content, " \t\n\r")
|
||||
prefix, rest := peelLicensePrefix(content)
|
||||
next, removed := stripLeadingLicenseComment(rest)
|
||||
if !removed {
|
||||
return content, false
|
||||
}
|
||||
return prefix + next, true
|
||||
}
|
||||
|
||||
// stripLeadingLicenseComment removes a license header at the top of s, handling
|
||||
// C-style /* */ blocks, contiguous // line comments, and contiguous # comments.
|
||||
func stripLeadingLicenseComment(s string) (string, bool) {
|
||||
trimmed := strings.TrimLeft(s, " \t\n\r")
|
||||
|
||||
// C-style block comment at top
|
||||
if strings.HasPrefix(trimmed, "/*") {
|
||||
end := strings.Index(trimmed, "*/")
|
||||
if end >= 0 {
|
||||
candidate := trimmed[:end+2]
|
||||
lower := strings.ToLower(candidate)
|
||||
if strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier") {
|
||||
if containsLicenseKeyword(strings.ToLower(candidate)) {
|
||||
rest := trimmed[end+2:]
|
||||
// Consume trailing newline(s)
|
||||
rest = strings.TrimLeft(rest, "\r\n")
|
||||
@@ -81,6 +121,24 @@ func stripLicense(content string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// "//" line-comment header (Go/C/TS). Only contiguous "//" lines belong to
|
||||
// the header; a non-comment line ends it and is preserved.
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
var commentLines, rest []string
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "//") {
|
||||
commentLines = append(commentLines, l)
|
||||
continue
|
||||
}
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
// Python/hash-style leading comment block. Only contiguous "#" lines belong to the
|
||||
// header; a blank line ends it and is preserved as a separator (rather than being
|
||||
// greedily swallowed and collapsed away).
|
||||
@@ -95,15 +153,12 @@ func stripLicense(content string) (string, bool) {
|
||||
rest = lines[i:]
|
||||
break
|
||||
}
|
||||
lower := strings.ToLower(strings.Join(commentLines, "\n"))
|
||||
if strings.Contains(lower, "copyright") ||
|
||||
strings.Contains(lower, "license") ||
|
||||
strings.Contains(lower, "spdx-license-identifier") {
|
||||
if containsLicenseKeyword(strings.ToLower(strings.Join(commentLines, "\n"))) {
|
||||
return strings.Join(rest, "\n"), true
|
||||
}
|
||||
}
|
||||
|
||||
return content, false
|
||||
return s, false
|
||||
}
|
||||
|
||||
// stripImports removes top-of-file import blocks, language-specific.
|
||||
@@ -147,7 +202,10 @@ func stripGoImports(content string) (string, bool) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimLine, `import "`) || strings.HasPrefix(trimLine, "import `") {
|
||||
// Single import spec in any form: import "path", import `path`,
|
||||
// import alias "path", import _ "path", import . "path".
|
||||
if strings.HasPrefix(trimLine, "import ") &&
|
||||
(strings.Contains(trimLine, `"`) || strings.Contains(trimLine, "`")) {
|
||||
removed = true
|
||||
i++
|
||||
continue
|
||||
@@ -161,15 +219,45 @@ func stripGoImports(content string) (string, bool) {
|
||||
return strings.Join(out, "\n"), true
|
||||
}
|
||||
|
||||
// stripTSImports removes TypeScript/JavaScript "import ... from ..." and "require(...)" lines.
|
||||
// tsImportNeedsContinuation reports whether an import line (known to start an
|
||||
// import) continues onto following lines. A multi-line named import opens a
|
||||
// brace and only reaches its module specifier ("from '...'") on a later line.
|
||||
func tsImportNeedsContinuation(line string) bool {
|
||||
if strings.Contains(line, "from ") || strings.HasSuffix(line, ";") {
|
||||
return false
|
||||
}
|
||||
// Side-effect import (import '...' / import "...") is single-line.
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(line, "import"))
|
||||
if strings.HasPrefix(rest, "'") || strings.HasPrefix(rest, `"`) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripTSImports removes TypeScript/JavaScript "import ... from ..." (including
|
||||
// multi-line named imports) and destructured require() lines.
|
||||
func stripTSImports(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
var out []string
|
||||
removed := false
|
||||
inMulti := false
|
||||
for _, l := range lines {
|
||||
trimLine := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(") {
|
||||
if inMulti {
|
||||
removed = true
|
||||
// The module-specifier line ("from '...'") or a statement terminator ends it.
|
||||
if strings.Contains(trimLine, "from ") || strings.HasSuffix(trimLine, ";") {
|
||||
inMulti = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
isImport := strings.HasPrefix(trimLine, "import ") || strings.HasPrefix(trimLine, "import{")
|
||||
isRequire := strings.HasPrefix(trimLine, "const {") && strings.Contains(trimLine, "require(")
|
||||
if isImport || isRequire {
|
||||
removed = true
|
||||
if isImport && tsImportNeedsContinuation(trimLine) {
|
||||
inMulti = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, l)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
@@ -45,3 +46,55 @@ func TestStripLicensePythonPreservesSeparatorBlank(t *testing.T) {
|
||||
t.Fatalf("python license strip must keep the blank separator.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-line TS named import must be removed in full, not just its first line.
|
||||
func TestStripTSImportsMultiLine(t *testing.T) {
|
||||
src := "import {\n a,\n b,\n} from 'x';\nconst y = 1;\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1;\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("multi-line import must be fully removed.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A side-effect import without a semicolon must not consume following code.
|
||||
func TestStripTSImportsSideEffectNoRunaway(t *testing.T) {
|
||||
src := "import './setup'\nconst y = 1\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangTypeScript)
|
||||
want := "const y = 1\n"
|
||||
if got.Content != want {
|
||||
t.Fatalf("side-effect import must not swallow code.\nwant: %q\ngot: %q", want, got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Blank/underscore/dot/aliased single Go imports must all be stripped.
|
||||
func TestStripGoImportsSingleSpecForms(t *testing.T) {
|
||||
src := "package main\n\nimport _ \"embed\"\nimport . \"math\"\nimport m \"strings\"\n\nfunc main() {}\n"
|
||||
got := StripContent(src, []StripFlag{StripImports}, protocol.LangGo)
|
||||
for _, frag := range []string{`"embed"`, `"math"`, `"strings"`} {
|
||||
if containsStr(got.Content, frag) {
|
||||
t.Fatalf("single import %s not stripped, got:\n%s", frag, got.Content)
|
||||
}
|
||||
}
|
||||
if !containsStr(got.Content, "func main") {
|
||||
t.Fatalf("code removed, got:\n%s", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A Go // line-comment license after build constraints: the license is removed
|
||||
// while the build constraint is preserved above it.
|
||||
func TestStripLicenseGoLineCommentAfterBuildTag(t *testing.T) {
|
||||
src := "//go:build linux\n\n// Copyright 2024 Acme\n// SPDX-License-Identifier: MIT\n\npackage main\n"
|
||||
got := StripContent(src, []StripFlag{StripLicense}, protocol.LangGo)
|
||||
if containsStr(got.Content, "Copyright") {
|
||||
t.Fatalf("line-comment license not stripped, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "//go:build linux") {
|
||||
t.Fatalf("build constraint must be preserved, got:\n%q", got.Content)
|
||||
}
|
||||
if !containsStr(got.Content, "package main") {
|
||||
t.Fatalf("code removed, got:\n%q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
|
||||
Reference in New Issue
Block a user