diff --git a/internal/parser/strip.go b/internal/parser/strip.go index e427c05..7f42307 100644 --- a/internal/parser/strip.go +++ b/internal/parser/strip.go @@ -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) diff --git a/internal/parser/strip_newline_test.go b/internal/parser/strip_newline_test.go index e973a14..1ec84ea 100644 --- a/internal/parser/strip_newline_test.go +++ b/internal/parser/strip_newline_test.go @@ -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) }