*/ ` names
+ // only P as a protocol.
+ return strings.Join(strings.Fields(internalast.HeaderText(n, src, start, end, nil)), " ")
}
// objcCategoryName returns the category name of `@interface Foo (Bar)` — a
@@ -6391,31 +6606,31 @@ func objcMethodDefSym(n *sitter.Node, parentName string, src []byte, out *[]astk
func objcMethodSigBeforeBody(n *sitter.Node, src []byte) string {
body := internalast.FindChildByType(n, "compound_statement")
if body == nil {
- return internalast.FirstLine(strings.TrimSpace(n.Content(src)))
+ return internalast.FirstLineSig(n, src, nil)
}
start := n.StartByte()
bodyStart := body.StartByte()
if bodyStart <= start {
- return internalast.FirstLine(n.Content(src))
+ return internalast.FirstLineSig(n, src, nil)
}
- sig := strings.TrimSpace(string(src[start:bodyStart]))
- return strings.Join(strings.Fields(sig), " ")
+ return strings.Join(strings.Fields(internalast.HeaderText(n, src, start, bodyStart, nil)), " ")
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
-// funcSig returns the function/method signature without the body.
+// funcSig returns the function/method signature without the body, its
+// comments, or its C#/PHP attribute sections (those are on Annotations).
func funcSig(n *sitter.Node, src []byte) string {
body := n.ChildByFieldName("body")
if body == nil {
- return strings.TrimSpace(n.Content(src))
+ return strings.TrimSpace(internalast.HeaderText(n, src, n.StartByte(), n.EndByte(), internalast.IsAttributeSection))
}
start := n.StartByte()
bodyStart := body.StartByte()
if bodyStart <= start {
- return internalast.FirstLine(n.Content(src))
+ return internalast.FirstLineSig(n, src, internalast.IsAttributeSection)
}
- sig := strings.TrimSpace(string(src[start:bodyStart]))
+ sig := strings.TrimSpace(internalast.HeaderText(n, src, start, bodyStart, internalast.IsAttributeSection))
sig = strings.TrimRight(sig, " \t\n{")
return strings.TrimSpace(sig)
}
diff --git a/strategies/metadata.go b/strategies/metadata.go
index 8d27091..04f8631 100644
--- a/strategies/metadata.go
+++ b/strategies/metadata.go
@@ -9,6 +9,7 @@ import (
"github.com/provasign/astkit"
"github.com/provasign/astkit/internalast"
+ "github.com/provasign/astkit/textmask"
)
var _ = internalast.NodeSpan // imported by extractors.go callers
@@ -815,10 +816,6 @@ func rustCallSites(body *sitter.Node, src []byte) []astkit.CallSite {
return out
}
-// rustMacroStringRe strips string literals from macro token trees so format
-// strings can't fabricate call sites.
-var rustMacroStringRe = regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
-
// rustMacroCallRe finds call-shaped token runs inside a macro's token tree:
// "unescape(", "m.start(", "SearcherTester::new(".
var rustMacroCallRe = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*(?:(?:::|\.)[A-Za-z_][A-Za-z0-9_]*)*)\s*\(`)
@@ -846,7 +843,9 @@ func rustMacroCallSites(body *sitter.Node, src []byte) []astkit.CallSite {
}
if n.Type() == "macro_invocation" {
if tt := internalast.FindChildByType(n, "token_tree"); tt != nil {
- text := rustMacroStringRe.ReplaceAllString(tt.Content(src), `""`)
+ // Comments and string/char literals in the token tree
+ // are prose, not calls: `assert!(ok, "see bar(1)")`.
+ text := textmask.Mask("rust", tt.Content(src))
line := int(n.StartPoint().Row) + 1
for _, m := range rustMacroCallRe.FindAllStringSubmatchIndex(text, -1) {
path := text[m[2]:m[3]]
@@ -1541,8 +1540,8 @@ func javaModifiers(n *sitter.Node, src []byte) []string {
if c == nil {
continue
}
- switch c.Type() {
- case "marker_annotation", "annotation":
+ switch {
+ case c.Type() == "marker_annotation" || c.Type() == "annotation", internalast.IsComment(c):
continue
default:
text := strings.TrimSpace(c.Content(src))
diff --git a/strategies/objc_round2_test.go b/strategies/objc_round2_test.go
index 11be04e..90b7fa9 100644
--- a/strategies/objc_round2_test.go
+++ b/strategies/objc_round2_test.go
@@ -180,3 +180,49 @@ func TestObjCPlainCFunctionStillExtracted(t *testing.T) {
t.Fatalf("plain C function 'add' not extracted; symbols: %+v", syms)
}
}
+
+func TestObjCContainersInsideConditionals(t *testing.T) {
+ // AFNetworking's UIKit headers wrap the @interface in `#if
+ // TARGET_OS_IOS`; the grammar nests it under preproc_if.
+ src := `#if TARGET_OS_IOS
+@interface Indicator : NSObject
+@property (nonatomic, assign) BOOL enabled;
+- (void)increment;
+@end
+#else
+@protocol Fallback
+- (void)noop;
+@end
+#endif
+#if 0
+@interface Dead : NSObject
+@end
+#endif
+#ifdef __cplusplus
+extern "C" {
+#endif
+@implementation Indicator
+- (void)increment {
+}
+@end
+#ifdef __cplusplus
+}
+#endif
+`
+ syms, _ := extract(t, astkit.LangObjC, src)
+ names := map[string]bool{}
+ for _, s := range syms {
+ names[s.QualifiedName] = true
+ if s.ParentName != "" {
+ names[s.ParentName+"."+s.Name] = true
+ }
+ }
+ for _, want := range []string{"Indicator", "Indicator.enabled", "Indicator.increment", "Fallback", "Fallback.noop"} {
+ if !names[want] {
+ t.Errorf("%s missing; symbols: %v", want, names)
+ }
+ }
+ if names["Dead"] {
+ t.Errorf("#if 0 interface extracted: %v", names)
+ }
+}
diff --git a/textmask/oracle_test.go b/textmask/oracle_test.go
new file mode 100644
index 0000000..bd05e18
--- /dev/null
+++ b/textmask/oracle_test.go
@@ -0,0 +1,491 @@
+package textmask
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ sitter "github.com/smacker/go-tree-sitter"
+ "github.com/smacker/go-tree-sitter/c"
+ "github.com/smacker/go-tree-sitter/cpp"
+ "github.com/smacker/go-tree-sitter/csharp"
+ "github.com/smacker/go-tree-sitter/golang"
+ "github.com/smacker/go-tree-sitter/java"
+ "github.com/smacker/go-tree-sitter/javascript"
+ "github.com/smacker/go-tree-sitter/kotlin"
+ "github.com/smacker/go-tree-sitter/php"
+ "github.com/smacker/go-tree-sitter/python"
+ "github.com/smacker/go-tree-sitter/rust"
+ "github.com/smacker/go-tree-sitter/swift"
+ tstsx "github.com/smacker/go-tree-sitter/typescript/tsx"
+ tstype "github.com/smacker/go-tree-sitter/typescript/typescript"
+
+ "github.com/provasign/astkit/thirdparty/tsobjc"
+)
+
+// The oracle: tree-sitter's comment and literal nodes say which bytes are
+// prose or literal text. Mask must blank every non-space byte inside them
+// (minus interpolation holes, which are code) and no non-space byte outside.
+
+func grammar(lang string) *sitter.Language {
+ switch lang {
+ case "go":
+ return golang.GetLanguage()
+ case "typescript":
+ return tstype.GetLanguage()
+ case "tsx":
+ return tstsx.GetLanguage()
+ case "javascript":
+ return javascript.GetLanguage()
+ case "python":
+ return python.GetLanguage()
+ case "java":
+ return java.GetLanguage()
+ case "rust":
+ return rust.GetLanguage()
+ case "c":
+ return c.GetLanguage()
+ case "cpp":
+ return cpp.GetLanguage()
+ case "csharp":
+ return csharp.GetLanguage()
+ case "php":
+ return php.GetLanguage()
+ case "swift":
+ return swift.GetLanguage()
+ case "kotlin":
+ return kotlin.GetLanguage()
+ case "objc":
+ return tsobjc.GetLanguage()
+ }
+ return nil
+}
+
+// langOfPath maps a file extension to a language key.
+func langOfPath(p string) string {
+ switch strings.ToLower(filepath.Ext(p)) {
+ case ".go":
+ return "go"
+ case ".ts", ".mts", ".cts":
+ return "typescript"
+ case ".tsx":
+ return "tsx"
+ case ".js", ".jsx", ".mjs", ".cjs":
+ return "javascript"
+ case ".py":
+ return "python"
+ case ".java":
+ return "java"
+ case ".rs":
+ return "rust"
+ case ".c":
+ return "c"
+ case ".h":
+ return "cpp"
+ case ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx":
+ return "cpp"
+ case ".m", ".mm":
+ return "objc"
+ case ".cs":
+ return "csharp"
+ case ".php":
+ return "php"
+ case ".swift":
+ return "swift"
+ case ".kt", ".kts":
+ return "kotlin"
+ case ".cbl", ".cob", ".cpy":
+ return "cobol"
+ case ".jcl":
+ return "jcl"
+ }
+ return ""
+}
+
+// Node classes. A node type is looked up in these sets; anything else is
+// walked into.
+var (
+ // literalTypes: every byte is literal text, except interpolation children.
+ literalTypes = map[string]bool{
+ "string": true, "string_literal": true, "raw_string_literal": true,
+ "char_literal": true, "character_literal": true,
+ "interpreted_string_literal": true, "rune_literal": true,
+ "template_string": true, "regex": true,
+ "encapsed_string": true, "heredoc": true, "nowdoc": true, "heredoc_body": true,
+ "shell_command_expression": true,
+ "verbatim_string_literal": true, "interpolated_string_expression": true,
+ "interpolated_verbatim_string_text": true, "interpolated_raw_string_text": true,
+ "raw_string_start": true, "raw_string_end": true, "raw_string_content": true,
+ "text_block": true, "line_string_literal": true,
+ "multi_line_string_literal": true, "raw_string_literal_content": true,
+ "string_content": true,
+ "text": true, // PHP inline HTML
+ "template_literal_type": true, "hash_bang_line": true,
+ }
+ // holeTypes: interpolation children inside literals; they are code.
+ holeTypes = map[string]bool{
+ "interpolation": true, "template_substitution": true,
+ "string_interpolation": true, "interpolated_expression": true,
+ "interpolated_identifier": true, "raw_str_interpolation": true,
+ "variable_name": true, "member_access_expression": true,
+ "subscript_expression": true, "dynamic_variable_name": true,
+ "simple_expression": true, "template_type": true,
+ }
+ // dontCareTypes: either masked or kept is acceptable.
+ dontCareTypes = map[string]bool{
+ "format_specifier": true, "interpolation_format_clause": true,
+ "preproc_arg": true,
+ // Swift `#error("...")` and `#if ... // c`: tree-sitter swallows the
+ // string and the trailing comment into the directive token.
+ "diagnostic": true, "directive": true,
+ }
+)
+
+// phpLitParents are PHP literal nodes whose named children are either text
+// parts or interpolated expressions (holes) of any expression type.
+var phpLitParents = map[string]bool{
+ "encapsed_string": true, "heredoc_body": true, "shell_command_expression": true,
+}
+
+var textParts = map[string]bool{
+ "string_content": true, "string_value": true, "escape_sequence": true,
+ "heredoc_start": true, "heredoc_end": true, "nowdoc_body": true,
+ "heredoc_body": true, "string": true, "text": true,
+}
+
+// isHole reports whether child ch of a literal node of type parent is an
+// interpolation hole.
+func isHole(parent string, ch *sitter.Node) bool {
+ if holeTypes[ch.Type()] {
+ return true
+ }
+ return phpLitParents[parent] && ch.IsNamed() && !textParts[ch.Type()]
+}
+
+// expectations builds the per-byte oracle: 0 code, 1 masked, 2 don't care.
+// Nodes only reachable through a hole of PHP's grammar (variable_name etc.)
+// are holes only when they are children of a literal.
+func expectations(root *sitter.Node, n int) []byte {
+ exp := make([]byte, n)
+ fill := func(nd *sitter.Node, v byte) {
+ a, b := int(nd.StartByte()), int(nd.EndByte())
+ if b > n {
+ b = n
+ }
+ for k := a; k < b; k++ {
+ exp[k] = v
+ }
+ }
+ var walk func(nd *sitter.Node, inLit bool)
+ // walkLit visits a literal's children: holes are code (walked afresh),
+ // nested literal parts stay masked.
+ walkLit := func(nd *sitter.Node) {
+ for k := 0; k < int(nd.ChildCount()); k++ {
+ ch := nd.Child(k)
+ if isHole(nd.Type(), ch) {
+ fill(ch, 0)
+ for j := 0; j < int(ch.ChildCount()); j++ {
+ walk(ch.Child(j), false)
+ }
+ continue
+ }
+ walk(ch, true)
+ }
+ }
+ walk = func(nd *sitter.Node, inLit bool) {
+ t := nd.Type()
+ switch {
+ case strings.Contains(t, "comment") && nd.IsNamed():
+ fill(nd, 1)
+ return
+ case dontCareTypes[t]:
+ fill(nd, 2)
+ return
+ case literalTypes[t] && nd.IsNamed():
+ fill(nd, 1)
+ walkLit(nd)
+ return
+ }
+ for k := 0; k < int(nd.ChildCount()); k++ {
+ walk(nd.Child(k), inLit)
+ }
+ }
+ walk(root, false)
+ return exp
+}
+
+// holePunct relaxes the oracle for interpolation delimiters: grammars
+// disagree on whether `${`, `\(`, `{`, `}` and `)` belong to the hole.
+func relaxHolePunct(src string, exp []byte, root *sitter.Node) {
+ var walk func(nd *sitter.Node, inLit bool)
+ walk = func(nd *sitter.Node, inLit bool) {
+ t := nd.Type()
+ hole := false
+ if p := nd.Parent(); inLit && p != nil && isHole(p.Type(), nd) {
+ hole = true
+ }
+ if hole {
+ a, b := int(nd.StartByte()), int(nd.EndByte())
+ relaxAround(src, exp, a, -1)
+ relaxAround(src, exp, a, +1)
+ relaxAround(src, exp, b, -1)
+ relaxAround(src, exp, b, +1)
+ }
+ lit := inLit || literalTypes[t] && nd.IsNamed()
+ if hole {
+ lit = false
+ }
+ for k := 0; k < int(nd.ChildCount()); k++ {
+ walk(nd.Child(k), lit)
+ }
+ }
+ walk(root, false)
+}
+
+// relaxAround marks up to three hole-punctuation bytes next to offset k
+// (scanning in direction dir, skipping whitespace) as don't-care.
+func relaxAround(src string, exp []byte, k, dir int) {
+ if dir < 0 {
+ k--
+ }
+ marked := 0
+ for k >= 0 && k < len(src) && marked < 3 {
+ c := src[k]
+ switch {
+ case c == ' ' || c == '\t' || c == '\n' || c == '\r':
+ case strings.IndexByte("${}()\\#", c) >= 0:
+ exp[k] = 2
+ marked++
+ default:
+ return
+ }
+ k += dir
+ }
+}
+
+type mismatch struct {
+ line int
+ want byte
+ snippet string
+ nodeKind string
+}
+
+// checkOracle returns mismatches between Mask and tree-sitter for one source,
+// or ok=false when the source does not parse cleanly.
+func checkOracle(t testing.TB, lang, src string) ([]mismatch, bool) {
+ g := grammar(lang)
+ if g == nil {
+ return nil, false
+ }
+ p := sitter.NewParser()
+ defer p.Close()
+ p.SetLanguage(g)
+ tree, err := p.ParseCtx(context.Background(), nil, []byte(src))
+ if err != nil || tree == nil {
+ return nil, false
+ }
+ defer tree.Close()
+ root := tree.RootNode()
+ if root.HasError() {
+ return nil, false
+ }
+ exp := expectations(root, len(src))
+ relaxHolePunct(src, exp, root)
+ got := MaskFile(lang, src)
+ var out []mismatch
+ lastLine := -1
+ for k := 0; k < len(src); k++ {
+ c := src[k]
+ if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v' || exp[k] == 2 {
+ continue
+ }
+ masked := got[k] == ' '
+ if masked == (exp[k] == 1) {
+ continue
+ }
+ line := strings.Count(src[:k], "\n") + 1
+ if line == lastLine {
+ continue
+ }
+ lastLine = line
+ ls := strings.LastIndexByte(src[:k], '\n') + 1
+ le := strings.IndexByte(src[k:], '\n')
+ if le < 0 {
+ le = len(src)
+ } else {
+ le += k
+ }
+ snip := src[ls:le]
+ if len(snip) > 160 {
+ snip = snip[:160]
+ }
+ kind := ""
+ if nd := root.NamedDescendantForPointRange(pointAt(src, k), pointAt(src, k)); nd != nil {
+ kind = nd.Type()
+ if p := nd.Parent(); p != nil {
+ kind = p.Type() + ">" + kind
+ }
+ }
+ out = append(out, mismatch{line: line, want: exp[k], snippet: snip, nodeKind: kind})
+ }
+ return out, true
+}
+
+func pointAt(src string, k int) sitter.Point {
+ row := strings.Count(src[:k], "\n")
+ col := k - (strings.LastIndexByte(src[:k], '\n') + 1)
+ return sitter.Point{Row: uint32(row), Column: uint32(col)}
+}
+
+func TestMaskMatchesTreeSitter(t *testing.T) {
+ files, err := filepath.Glob("testdata/*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ checked := 0
+ for _, f := range files {
+ lang := langOfPath(f)
+ if grammar(lang) == nil {
+ continue
+ }
+ b, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ms, ok := checkOracle(t, lang, string(b))
+ if !ok {
+ if os.Getenv("TEXTMASK_DUMP") != "" {
+ dumpErrors(t, lang, string(b))
+ }
+ t.Errorf("%s: tree-sitter reports a parse error; fix the snippet", f)
+ continue
+ }
+ checked++
+ for _, m := range ms {
+ t.Errorf("%s:%d: want %s [%s]: %s", f, m.line, wantName(m.want), m.nodeKind, m.snippet)
+ }
+ }
+ if checked == 0 {
+ t.Fatal("no testdata checked")
+ }
+}
+
+func wantName(w byte) string {
+ if w == 1 {
+ return "masked"
+ }
+ return "code"
+}
+
+func dumpErrors(t testing.TB, lang, src string) {
+ p := sitter.NewParser()
+ defer p.Close()
+ p.SetLanguage(grammar(lang))
+ tree, _ := p.ParseCtx(context.Background(), nil, []byte(src))
+ defer tree.Close()
+ var walk func(nd *sitter.Node)
+ walk = func(nd *sitter.Node) {
+ if nd.IsError() || nd.IsMissing() {
+ t.Logf(" %s at %d:%d %q", nd.Type(), nd.StartPoint().Row+1, nd.StartPoint().Column, clip(src[nd.StartByte():nd.EndByte()]))
+ return
+ }
+ for k := 0; k < int(nd.ChildCount()); k++ {
+ walk(nd.Child(k))
+ }
+ }
+ walk(tree.RootNode())
+}
+
+func clip(s string) string {
+ if len(s) > 60 {
+ return s[:60]
+ }
+ return s
+}
+
+// TestMaskCorpus runs the oracle over a directory tree named by
+// TEXTMASK_CORPUS. TEXTMASK_CAP bounds files per language (default 400).
+func TestMaskCorpus(t *testing.T) {
+ root := os.Getenv("TEXTMASK_CORPUS")
+ if root == "" {
+ t.Skip("TEXTMASK_CORPUS not set")
+ }
+ limit := 400
+ if v, err := strconv.Atoi(os.Getenv("TEXTMASK_CAP")); err == nil && v > 0 {
+ limit = v
+ }
+ verbose := os.Getenv("TEXTMASK_VERBOSE") != ""
+ type stat struct{ seen, checked, parseErr, bad, badLines int }
+ stats := map[string]*stat{}
+ for _, dir := range strings.Split(root, string(os.PathListSeparator)) {
+ _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ switch d.Name() {
+ case "node_modules", ".git", "vendor", ".grove", "dist", "build":
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ lang := langOfPath(p)
+ if lang == "" || grammar(lang) == nil {
+ return nil
+ }
+ st := stats[lang]
+ if st == nil {
+ st = &stat{}
+ stats[lang] = st
+ }
+ if st.seen >= limit {
+ return nil
+ }
+ info, err := d.Info()
+ if err != nil || info.Size() > 512<<10 || info.Size() == 0 {
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return nil
+ }
+ st.seen++
+ ms, ok := checkOracle(t, lang, string(b))
+ if !ok {
+ st.parseErr++
+ return nil
+ }
+ st.checked++
+ if len(ms) > 0 {
+ st.bad++
+ st.badLines += len(ms)
+ shown := ms
+ if !verbose && len(shown) > 3 {
+ shown = shown[:3]
+ }
+ for _, m := range shown {
+ t.Logf("MISMATCH %s:%d want %s [%s]: %s", p, m.line, wantName(m.want), m.nodeKind, strings.TrimSpace(m.snippet))
+ }
+ }
+ return nil
+ })
+ }
+ langs := make([]string, 0, len(stats))
+ for l := range stats {
+ langs = append(langs, l)
+ }
+ sort.Strings(langs)
+ total := 0
+ for _, l := range langs {
+ st := stats[l]
+ t.Logf("%-10s files=%d checked=%d parse-errors=%d mismatched-files=%d mismatched-lines=%d",
+ l, st.seen, st.checked, st.parseErr, st.bad, st.badLines)
+ total += st.bad
+ }
+ fmt.Fprintf(os.Stderr, "textmask corpus: %d mismatched files\n", total)
+}
diff --git a/textmask/preproc.go b/textmask/preproc.go
new file mode 100644
index 0000000..97f488f
--- /dev/null
+++ b/textmask/preproc.go
@@ -0,0 +1,91 @@
+package textmask
+
+import "strings"
+
+// MaskInactivePreprocessor masks the lines of `#if 0` branches (C, C++,
+// Objective-C; `#if false` for C#), including any nested conditionals inside
+// them, up to the matching `#else`, `#elif` or `#endif`. The branch after
+// `#else`/`#elif` stays live. The directive lines of the disabled
+// conditional itself are kept; directives nested inside it are masked.
+// Comments and strings are not masked (compose with Mask for that), but they
+// are respected: a `#if 0` inside a block comment is not a directive. Other
+// languages return src unchanged.
+func MaskInactivePreprocessor(lang, src string) string {
+ var off []string
+ switch lang {
+ case "c", "cpp", "objc":
+ off = []string{"0", "(0)"}
+ case "csharp":
+ off = []string{"false", "(false)"}
+ default:
+ return src
+ }
+ if !strings.Contains(src, "#") {
+ return src
+ }
+ view := MaskComments(lang, src)
+ out := []byte(src)
+ changed := false
+ depth := 0 // > 0 while inside a disabled branch: conditional nesting depth
+ for ls := 0; ls < len(view); {
+ le := strings.IndexByte(view[ls:], '\n')
+ if le < 0 {
+ le = len(view)
+ } else {
+ le += ls
+ }
+ dir, arg := directive(view[ls:le])
+ switch {
+ case depth == 0:
+ if dir == "if" && contains(off, arg) {
+ depth = 1
+ }
+ case dir == "if" || dir == "ifdef" || dir == "ifndef":
+ depth++
+ fallthrough
+ default:
+ if depth == 1 && (dir == "else" || dir == "elif" || dir == "endif") {
+ depth = 0
+ break
+ }
+ if dir == "endif" {
+ depth--
+ }
+ for k := ls; k < le; k++ {
+ if c := out[k]; c != '\r' && c != '\n' {
+ out[k] = ' '
+ changed = true
+ }
+ }
+ }
+ ls = le + 1
+ }
+ if !changed {
+ return src
+ }
+ return string(out)
+}
+
+// directive splits a preprocessor line into its directive name and its
+// argument text (trimmed). Non-directive lines return "", "".
+func directive(line string) (string, string) {
+ t := strings.TrimLeft(line, " \t")
+ if !strings.HasPrefix(t, "#") {
+ return "", ""
+ }
+ t = strings.TrimLeft(t[1:], " \t")
+ k := 0
+ for k < len(t) && isIdentByte(t[k]) {
+ k++
+ }
+ return t[:k], strings.TrimSpace(t[k:])
+}
+
+func contains(list []string, s string) bool {
+ for _, v := range list {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/textmask/testdata/c_basic.c b/textmask/testdata/c_basic.c
new file mode 100644
index 0000000..305b762
--- /dev/null
+++ b/textmask/testdata/c_basic.c
@@ -0,0 +1,19 @@
+#include
+#include "local/header.h"
+#define GREETING "hello"
+#define MULTI(a) \
+ do { (a)++; } while (0)
+
+// line comment \
+ continued: int phantom(int x);
+/* block with 'quote and "dq" */
+int main(void) {
+ char c = '\'';
+ char d = '"';
+ const char *s = "esc \" quote // not comment";
+ const char *w = L"wide";
+ const char *cont = "line \
+continued";
+ printf("%s %c %c %s %s\n", s, c, d, w, cont);
+ return 0;
+}
diff --git a/textmask/testdata/cobol_fixed.cbl b/textmask/testdata/cobol_fixed.cbl
new file mode 100644
index 0000000..07e92b6
--- /dev/null
+++ b/textmask/testdata/cobol_fixed.cbl
@@ -0,0 +1,8 @@
+000100 IDENTIFICATION DIVISION. PROG0001
+000200* THIS IS A COMMENT LINE WITH 'QUOTE PROG0002
+000300 PROGRAM-ID. HELLO. PROG0003
+000400/ PAGE EJECT COMMENT PROG0004
+000500 PROCEDURE DIVISION. PROG0005
+000600 DISPLAY 'IT''S A STRING'. PROG0006
+000700 DISPLAY "DOUBLE ""Q""". *> INLINE COMMENT PROG0007
+000800 STOP RUN. PROG0008
diff --git a/textmask/testdata/cobol_free.cbl b/textmask/testdata/cobol_free.cbl
new file mode 100644
index 0000000..bd7c163
--- /dev/null
+++ b/textmask/testdata/cobol_free.cbl
@@ -0,0 +1,8 @@
+ >>SOURCE FORMAT FREE
+IDENTIFICATION DIVISION.
+*> free-format comment with 'quote
+PROGRAM-ID. HELLO.
+PROCEDURE DIVISION.
+* not a comment in free format
+ DISPLAY 'FREE ''TEXT'''. *> trailing
+ STOP RUN.
diff --git a/textmask/testdata/cpp_basic.cpp b/textmask/testdata/cpp_basic.cpp
new file mode 100644
index 0000000..20c7216
--- /dev/null
+++ b/textmask/testdata/cpp_basic.cpp
@@ -0,0 +1,25 @@
+#include
+#include
+
+namespace foo {
+constexpr int k = 1'000;
+}
+using namespace foo;
+constexpr long big = 1'000'000;
+constexpr unsigned hex = 0xFF'FF;
+
+const char *raw = R"(
+int phantom(int x) {
+)";
+const char *raw2 = R"xy(has )" inside)xy";
+const char *raw3 = u8R"(utf8 raw)";
+auto raw4 = LR"(wide raw)";
+auto raw5 = uR"(u16)";
+auto raw6 = UR"delim(u32 "q")delim";
+const char *u8s = u8"utf8";
+char16_t ch = u'x';
+
+class Real {
+public:
+ int value() const { return k; } // comment don't
+};
diff --git a/textmask/testdata/cpp_error.cpp b/textmask/testdata/cpp_error.cpp
new file mode 100644
index 0000000..af21ef7
--- /dev/null
+++ b/textmask/testdata/cpp_error.cpp
@@ -0,0 +1,4 @@
+#if 0
+#error don't build this
+#endif
+int after_error = 1;
diff --git a/textmask/testdata/csharp_basic.cs b/textmask/testdata/csharp_basic.cs
new file mode 100644
index 0000000..d12747a
--- /dev/null
+++ b/textmask/testdata/csharp_basic.cs
@@ -0,0 +1,30 @@
+#region Don't parse this region name
+using System;
+
+namespace Demo
+{
+ /// Doc comment with 'apostrophe
+ [Description("class Y : Fake")]
+ public class X
+ {
+ string path = @"C:\x\";
+ string verb = @"line one
+line ""two""";
+ string interp = $"{a}";
+ string fmt = $"{price:N2} and {{literal}} {Call(1)}";
+ string vi = $@"C:\{dir}\file";
+ string iv = @$"{dir}\x";
+ string raw = """raw "quoted" text""";
+ string raw2 = """
+ class Phantom { }
+ """;
+ string raw3 = $$"""{{Name}} and {literal}""";
+ char c = '\'';
+ char q = '"';
+ int a = 1, dir = 2, price = 3;
+ string Name = "n";
+ int Call(int x) => x;
+ string nested = $"{(a > 1 ? "yes" : "no")}";
+ }
+}
+#endregion
diff --git a/textmask/testdata/go_basic.go b/textmask/testdata/go_basic.go
new file mode 100644
index 0000000..9c7bd56
--- /dev/null
+++ b/textmask/testdata/go_basic.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Comment with 'apostrophe and "quote"
+/* block comment
+ func phantom() {}
+*/
+func fix(p string) string {
+ p = strings.ReplaceAll(p, `\`, "/")
+ r := '\''
+ q := '"'
+ u := 'é'
+ raw := `
+func phantom2() {
+ return "x"
+}
+`
+ s := "escaped \" quote // not comment"
+ fmt.Println(r, q, u, raw, s)
+ return p
+}
diff --git a/textmask/testdata/java_basic.java b/textmask/testdata/java_basic.java
new file mode 100644
index 0000000..229573d
--- /dev/null
+++ b/textmask/testdata/java_basic.java
@@ -0,0 +1,27 @@
+package x;
+
+import java.util.List;
+
+/**
+ * Javadoc: class Phantom { void m() {} }
+ */
+@SuppressWarnings("unchecked")
+public class Basic {
+ // line comment with 'apostrophe
+ private String tb = """
+ He said "hi" and ""
+ then \""" escaped
+ class Inner {}
+ """;
+ private char c = '\'';
+ private char d = '"';
+ private String s = "a \"quoted\" // not comment";
+ int n = 1_000_000;
+
+ void run() {
+ String odd = """
+ one " two "" three \"""
+ """;
+ System.out.println(odd + s + c + d + tb);
+ }
+}
diff --git a/textmask/testdata/jcl_basic.jcl b/textmask/testdata/jcl_basic.jcl
new file mode 100644
index 0000000..31e2ab8
--- /dev/null
+++ b/textmask/testdata/jcl_basic.jcl
@@ -0,0 +1,4 @@
+//MYJOB JOB (ACCT),'PROGRAMMER NAME',CLASS=A
+//* THIS IS A COMMENT WITH 'QUOTE
+//STEP1 EXEC PGM=IEFBR14,PARM='IT''S'
+//DD1 DD DSN=MY.DATA.SET,DISP=SHR
diff --git a/textmask/testdata/js_basic.js b/textmask/testdata/js_basic.js
new file mode 100644
index 0000000..7f36820
--- /dev/null
+++ b/textmask/testdata/js_basic.js
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+// line comment with 'apostrophe
+/* block: function phantom() {} */
+class Widget {
+ #priv = 1;
+ #tpl;
+ constructor(b) {
+ this.#tpl = `a ${b} c`;
+ this.re = /["']/g;
+ this.re2 = /[/]\/x/;
+ this.ratio = this.#priv / 2 / 3;
+ const nested = `outer ${cond ? `inner ${b + 1}` : 'no'} end`;
+ const multi = `line one
+function phantom2() {}
+line ${b}`;
+ const s = 'it\'s' + "say \"hi\"";
+ const cont = 'line \
+continued';
+ if (/^x/.test(s)) {
+ return typeof /y/;
+ }
+ }
+}
+const obj = { re: /a\/b/i, n: (1) / 2 };
+export default Widget;
diff --git a/textmask/testdata/kotlin_basic.kt b/textmask/testdata/kotlin_basic.kt
new file mode 100644
index 0000000..e7bf0f3
--- /dev/null
+++ b/textmask/testdata/kotlin_basic.kt
@@ -0,0 +1,18 @@
+package demo
+
+/* outer /* nested */ fun phantom() {} */
+// line comment with 'apostrophe
+class Greeter(val name: String) {
+ fun label(n: Int): String = "n$n"
+
+ fun greet(): String {
+ val v = "v=${label(3)} and $name and \"esc\""
+ val raw = """
+ fun phantom2() {}
+ ${label(4)} $name
+ """
+ val c = '\''
+ val q = '"'
+ return v + raw + c + q
+ }
+}
diff --git a/textmask/testdata/objc_basic.m b/textmask/testdata/objc_basic.m
new file mode 100644
index 0000000..92c6a1a
--- /dev/null
+++ b/textmask/testdata/objc_basic.m
@@ -0,0 +1,15 @@
+#import
+
+// comment with 'apostrophe
+@interface Greeter : NSObject
+- (NSString *)greet;
+@end
+
+@implementation Greeter
+- (NSString *)greet {
+ /* block: - (void)phantom; */
+ NSString *s = @"hello \"world\" // not comment";
+ char c = '\'';
+ return s;
+}
+@end
diff --git a/textmask/testdata/php_basic.php b/textmask/testdata/php_basic.php
new file mode 100644
index 0000000..0ec4730
--- /dev/null
+++ b/textmask/testdata/php_basic.php
@@ -0,0 +1,32 @@
+act(2)} and $n and {$n} and $this->name";
+ $arr = "v=$items[0] w=$obj->prop";
+ $here = <<act(3)} with $n
+function phantom2() {}
+EOT;
+ $now = <<<'NOW'
+Nowdoc {$this->act(4)} raw $n
+NOW;
+ $indented = <<<"IND"
+ text $n
+ IND;
+ $cmd = `ls $dir`;
+ return $s . $d . $arr . $here . $now . $indented . $cmd;
+ }
+}
+?>
+inline HTML don't parse
+
diff --git a/textmask/testdata/python_312.py b/textmask/testdata/python_312.py
new file mode 100644
index 0000000..0426575
--- /dev/null
+++ b/textmask/testdata/python_312.py
@@ -0,0 +1,6 @@
+songs = ["a", "b"]
+s = f"{", ".join(songs)}"
+v = f"{f"{inner(1)}"}"
+d = f"{x=}"
+e = f"{'\n'.join(rows)}"
+k = f"{a!r:>{w}}"
diff --git a/textmask/testdata/python_basic.py b/textmask/testdata/python_basic.py
new file mode 100644
index 0000000..67136a6
--- /dev/null
+++ b/textmask/testdata/python_basic.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class Svc:
+ '''Don't do this: self.cache = Redis()'''
+
+ def start(self):
+ log.info('starting up')
+ self.client = Foo()
+ x = 'a'
+ y = "it's # not a comment"
+ z = r"\"" # raw string with escaped quote
+ w = rb'\x00' + Rb"bytes" + BR'more' + u'uni'
+ doc = """
+ def phantom(self):
+ return 'nope'
+ """
+ msg = f"v={label(3)} {{literal}} {x!r} {y:>10} {z:{width}}"
+ nested = f"{d['key']} and {', '.join(items)}"
+ multi = f'''
+ {compute(1,
+ 2)}
+ '''
+ cont = 'line one \
+line two'
+ named = f"\N{EM DASH} {value}"
+ rawf = rf"\d+{pattern}"
+ return self.client # trailing comment with 'quote
diff --git a/textmask/testdata/rust_basic.rs b/textmask/testdata/rust_basic.rs
new file mode 100644
index 0000000..7db5a10
--- /dev/null
+++ b/textmask/testdata/rust_basic.rs
@@ -0,0 +1,46 @@
+#![allow(dead_code)]
+//! Crate docs with 'quote
+use std::fmt;
+
+/// Docs: don't parse this
+#[derive(Debug, Clone)]
+struct Holder<'a> {
+ name: &'a str,
+}
+
+/* outer /* nested */ still comment fn phantom() {} */
+fn f<'a>(x: &'a str) -> char {
+ '"'
+}
+
+fn g() -> &'static str {
+ let f = r#"
+let client = Bar::new();
+"#;
+ let h = r##"has "# inside"##;
+ let b = b"bytes\"";
+ let br = br#"raw "bytes""#;
+ let c = c"cstr";
+ let ch = 'a';
+ let nl = '\n';
+ let uni = '\u{1F600}';
+ let e = 'é';
+ let by = b'x';
+ let bq = b'\'';
+ let r#type = 1;
+ let multi = "line one
+line two";
+ 'outer: loop {
+ break 'outer;
+ }
+ let _ = (f, h, b, br, c, ch, nl, uni, e, by, bq, r#type, multi);
+ "done"
+}
+
+fn lifetimes<'b, '_>(x: &'b u8) {}
+
+impl<'a> fmt::Display for Holder<'a> {
+ fn fmt(&self, w: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(w, "{}", self.name)
+ }
+}
diff --git a/textmask/testdata/swift_basic.swift b/textmask/testdata/swift_basic.swift
new file mode 100644
index 0000000..c454733
--- /dev/null
+++ b/textmask/testdata/swift_basic.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+/* outer /* nested */ func phantom() {} */
+// line comment with 'apostrophe
+struct Greeter {
+ let name: String
+
+ func label(_ n: Int) -> String { return "n" }
+
+ func greet() -> String {
+ let v = "v=\(label(3)) and \"esc\""
+ let raw = #"raw "quoted""#
+ let rawHole = #"hole \#(label(4)) and \(notHole)"#
+ let multi = """
+ func phantom2() {}
+ \(label(5))
+ """
+ let deeper = ##"two "# hashes"##
+ return v + raw + rawHole + multi + deeper + name
+ }
+}
diff --git a/textmask/testdata/ts_basic.ts b/textmask/testdata/ts_basic.ts
new file mode 100644
index 0000000..c20674b
--- /dev/null
+++ b/textmask/testdata/ts_basic.ts
@@ -0,0 +1,19 @@
+// TypeScript sample with 'apostrophe
+interface Shape {
+ name: string; // don't
+}
+
+type Tpl = `prefix-${string}`;
+
+export function area(s: T, w: number, h: number): number {
+ const label = `shape ${s.name}: ${w * h}`;
+ const parts = s.name.split(/[,;]/);
+ const half = w / 2;
+ return half * h + parts.length + label.length;
+}
+
+/** Doc: class Phantom {} */
+export class Box implements Shape {
+ #secret = 'x';
+ name = "box";
+}
diff --git a/textmask/testdata/tsx_basic.tsx b/textmask/testdata/tsx_basic.tsx
new file mode 100644
index 0000000..889ef5e
--- /dev/null
+++ b/textmask/testdata/tsx_basic.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+// component comment with 'apostrophe
+export function App(props: { title: string }) {
+ const cls = `app ${props.title}`;
+ return (
+
+ {props.title}
+ {/* jsx comment */}
+
+ );
+}
diff --git a/textmask/textmask.go b/textmask/textmask.go
new file mode 100644
index 0000000..724a7bd
--- /dev/null
+++ b/textmask/textmask.go
@@ -0,0 +1,1563 @@
+// Package textmask blanks comments and string literals out of source text so
+// that callers scanning for code (identifiers, calls, declarations) never
+// read prose or literal text as code.
+//
+// Every function preserves byte offsets: a masked byte becomes ' ' except
+// '\n' and '\r', which are kept, so line and column numbers are unchanged
+// and a match in the masked text can be sliced out of the original.
+//
+// The lexer is a single forward pass per language, linear in the input, with
+// no regular expressions. It is tolerant: unterminated comments and literals
+// run to the end of the input (or, for single-line literals, the end of the
+// line) instead of failing.
+//
+// Interpolation holes are code. `${...}` (JavaScript, TypeScript, Kotlin),
+// `{...}` (Python f/t-strings, C# interpolated strings), `\(...)` (Swift) and
+// `{$...}`, `${...}`, `$var`, `$var->prop`, `$var[...]` (PHP double quotes,
+// heredocs and backticks) are lexed recursively as code, so their identifiers
+// stay visible and literals inside them are masked in turn. The hole
+// punctuation itself (`${`, `}`, `\(`, `)`) is kept. Kotlin `$name` is kept
+// too. Format specifiers — the text after a top-level `:` in a Python or C#
+// hole (`{x:>10}`, `{d:yyyy-MM-dd}`) — are literal text and are masked, except
+// for nested holes inside them. A Python `!r`/`!s`/`!a` conversion is kept.
+//
+// Known gaps, all heuristic boundaries a lexer cannot settle without a
+// parser:
+// - JavaScript/TypeScript regex literals are told from division by the
+// previous token. `)` and `]` mean division (so `if (x) /re/.test(s)` is
+// misread), `}` means regex, except `{x} />` (a JSX self-closing tag).
+// A TypeScript non-null `x! / 2` is division.
+// - JSX/TSX text between tags is lexed as code: an apostrophe in
+// `Don't
` opens a quote that runs to the end of that line, and
+// `//` in `http://x` starts a comment. A quoted string right
+// after `=` may span lines (a JSX attribute value).
+// - Swift regex literals (`/.../`, `#/.../#`) are not recognized.
+// - C/C++ strings inside `#define` bodies are masked although tree-sitter
+// treats a macro body as opaque text; `#error`/`#warning` text is kept.
+// - COBOL continuation lines (`-` in column 7) are not joined; each line's
+// literal ends at that line's end.
+// - JCL: only `//*` comment lines and quoted strings are handled.
+//
+// Correctness is checked against tree-sitter (see oracle_test.go): set
+// TEXTMASK_CORPUS to a list of directories to run the comparison over real
+// code.
+package textmask
+
+import (
+ "strings"
+ "unicode/utf8"
+)
+
+// Languages lists the language keys Mask understands (astkit LanguageKey
+// strings), plus the alias "jsx" for JavaScript.
+var Languages = []string{
+ "go", "typescript", "tsx", "javascript", "jsx", "python", "java", "rust",
+ "c", "cpp", "csharp", "php", "swift", "kotlin", "objc", "cobol", "jcl",
+}
+
+// Supported reports whether lang is a key Mask understands. Mask returns the
+// input unchanged for any other key.
+func Supported(lang string) bool {
+ return familyOf(lang) != famNone
+}
+
+// Mask returns src with comment text and string/char literal text replaced by
+// spaces. Every byte keeps its offset: masked bytes become ' ', except '\n'
+// and '\r', which are kept, so line and column numbers are unchanged. Code
+// inside interpolation holes is kept and itself masked recursively. String
+// delimiters and prefixes (quotes, r#, @, $, R"delim() are masked too. src may
+// be a whole file or a symbol snippet; it is lexed as code from byte 0.
+func Mask(lang, src string) string {
+ return run(lang, src, true, false)
+}
+
+// MaskComments masks only comments; string literals are kept verbatim (they
+// are still lexed, so a comment marker inside a string is not a comment).
+// Useful for scans that read literal text, such as `#include "x.h"`.
+func MaskComments(lang, src string) string {
+ return run(lang, src, false, false)
+}
+
+// MaskFile is Mask for a whole file. It is identical to Mask except for PHP,
+// where the file starts in inline-HTML mode: text outside `` and
+// `= ... ?>` is masked.
+func MaskFile(lang, src string) string {
+ return run(lang, src, true, true)
+}
+
+func run(lang, src string, maskStrings, phpHTML bool) string {
+ fam := familyOf(lang)
+ if fam == famNone || src == "" {
+ return src
+ }
+ s := &scanner{
+ src: src,
+ out: []byte(src),
+ fam: fam,
+ lang: lang,
+ maskStrings: maskStrings,
+ }
+ switch fam {
+ case famCOBOL:
+ s.cobol()
+ case famJCL:
+ s.jcl()
+ case famPHP:
+ i := 0
+ if phpHTML {
+ i = s.phpHTML(0)
+ }
+ for i < len(src) {
+ i = s.code(i, 0, false)
+ if i < len(src) {
+ // code stopped at `?>`; the tag is code, the rest is HTML.
+ i = s.phpHTML(i + 2)
+ }
+ }
+ default:
+ s.code(0, 0, false)
+ }
+ return string(s.out)
+}
+
+type family int
+
+const (
+ famNone family = iota
+ famC // C, C++, Objective-C
+ famJava
+ famCSharp
+ famGo
+ famRust
+ famJS
+ famPython
+ famPHP
+ famKotlin
+ famSwift
+ famCOBOL
+ famJCL
+)
+
+func familyOf(lang string) family {
+ switch lang {
+ case "c", "cpp", "objc":
+ return famC
+ case "java":
+ return famJava
+ case "csharp":
+ return famCSharp
+ case "go":
+ return famGo
+ case "rust":
+ return famRust
+ case "javascript", "jsx", "typescript", "tsx":
+ return famJS
+ case "python":
+ return famPython
+ case "php":
+ return famPHP
+ case "kotlin":
+ return famKotlin
+ case "swift":
+ return famSwift
+ case "cobol":
+ return famCOBOL
+ case "jcl":
+ return famJCL
+ }
+ return famNone
+}
+
+type scanner struct {
+ src string
+ out []byte
+ fam family
+ lang string
+ maskStrings bool
+ // regexOK is the JavaScript previous-token state: true when a `/` at
+ // this point starts a regex literal rather than a division.
+ regexOK bool
+}
+
+// blank masks src[a:b], keeping line breaks.
+func (s *scanner) blank(a, b int) {
+ if b > len(s.out) {
+ b = len(s.out)
+ }
+ for k := a; k < b; k++ {
+ if c := s.out[k]; c != '\n' && c != '\r' {
+ s.out[k] = ' '
+ }
+ }
+}
+
+// lit masks literal text src[a:b] when strings are being masked.
+func (s *scanner) lit(a, b int) {
+ if s.maskStrings {
+ s.blank(a, b)
+ }
+}
+
+func isIdentByte(c byte) bool {
+ return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80
+}
+
+func isIdentStart(c byte) bool {
+ return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80
+}
+
+func isDigit(c byte) bool { return c >= '0' && c <= '9' }
+
+func isAlnum(c byte) bool {
+ return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
+}
+
+// at reports whether src has lit at offset i.
+func (s *scanner) at(i int, lit string) bool {
+ return i >= 0 && i+len(lit) <= len(s.src) && s.src[i:i+len(lit)] == lit
+}
+
+func (s *scanner) byteAt(i int) byte {
+ if i >= 0 && i < len(s.src) {
+ return s.src[i]
+ }
+ return 0
+}
+
+// eol returns the offset of the next '\n' at or after i, or len(src).
+func (s *scanner) eol(i int) int {
+ if k := strings.IndexByte(s.src[i:], '\n'); k >= 0 {
+ return i + k
+ }
+ return len(s.src)
+}
+
+// prevByte returns the last non-blank byte before i, or 0.
+func (s *scanner) prevByte(i int) byte {
+ for k := i - 1; k >= 0; k-- {
+ switch c := s.src[k]; c {
+ case ' ', '\t', '\n', '\r':
+ default:
+ return c
+ }
+ }
+ return 0
+}
+
+// backtickName skips a backtick-quoted identifier (Kotlin, Swift), which is
+// code even when it holds spaces or apostrophes: fun `it doesn't fail`().
+func (s *scanner) backtickName(i int) int {
+ for k := i + 1; k < len(s.src); k++ {
+ switch s.src[k] {
+ case '`':
+ return k + 1
+ case '\n':
+ return i + 1
+ }
+ }
+ return i + 1
+}
+
+// atLineStart reports whether only spaces and tabs precede i on its line.
+func (s *scanner) atLineStart(i int) bool {
+ for k := i - 1; k >= 0; k-- {
+ switch s.src[k] {
+ case ' ', '\t':
+ continue
+ case '\n', '\r':
+ return true
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// lineComment masks a comment from i to the end of its line and returns the
+// offset of the '\n'. With cont, a backslash before the newline continues the
+// comment onto the next line (C preprocessor semantics).
+func (s *scanner) lineComment(i int, cont bool) int {
+ k := s.eol(i)
+ for cont && k < len(s.src) {
+ j := k - 1
+ if j > i && s.src[j] == '\r' {
+ j--
+ }
+ if j < i || s.src[j] != '\\' {
+ break
+ }
+ k = s.eol(k + 1)
+ }
+ s.blank(i, k)
+ return k
+}
+
+// blockComment masks a `/* ... */` comment starting at i and returns the
+// offset after it. With nested, inner `/*` pairs nest (Rust, Kotlin, Swift).
+func (s *scanner) blockComment(i int, nested bool) int {
+ n := len(s.src)
+ k := i + 2
+ depth := 1
+ for k < n {
+ if s.src[k] == '*' && k+1 < n && s.src[k+1] == '/' {
+ k += 2
+ depth--
+ if depth == 0 {
+ break
+ }
+ continue
+ }
+ if nested && s.src[k] == '/' && k+1 < n && s.src[k+1] == '*' {
+ depth++
+ k += 2
+ continue
+ }
+ k++
+ }
+ if k > n {
+ k = n
+ }
+ s.blank(i, k)
+ return k
+}
+
+// quoted masks a literal opening with quote q at i (prefix bytes from start
+// are masked with it) and returns the offset after it. With esc, a backslash
+// skips the next byte. Without multiline, an unescaped newline ends it.
+func (s *scanner) quoted(start, i int, q byte, esc, multiline bool) int {
+ n := len(s.src)
+ k := i + 1
+ for k < n {
+ c := s.src[k]
+ if c == '\\' && esc {
+ k = s.esc(k)
+ continue
+ }
+ if c == q {
+ k++
+ break
+ }
+ if c == '\n' && !multiline {
+ break
+ }
+ k++
+ }
+ if k > n {
+ k = n
+ }
+ s.lit(start, k)
+ return k
+}
+
+// doubled masks a literal opening with quote q at i where a doubled quote is
+// an escaped quote (C# verbatim strings, COBOL, JCL). Multiline unless
+// oneLine.
+func (s *scanner) doubled(start, i int, q byte, oneLine bool) int {
+ n := len(s.src)
+ k := i + 1
+ for k < n {
+ c := s.src[k]
+ if c == q {
+ if k+1 < n && s.src[k+1] == q {
+ k += 2
+ continue
+ }
+ k++
+ break
+ }
+ if c == '\n' && oneLine {
+ break
+ }
+ k++
+ }
+ s.lit(start, k)
+ return k
+}
+
+// number skips a numeric literal starting at i. Digit separators: `_` in
+// every language, and `'` between alphanumerics in the C family (C++14,
+// C23), so `1'000` and `0xFF'FF` are not character literals.
+func (s *scanner) number(i int) int {
+ n := len(s.src)
+ k := i + 1
+ for k < n {
+ c := s.src[k]
+ switch {
+ case isIdentByte(c) || c == '.':
+ k++
+ case (c == '+' || c == '-') && (s.src[k-1] == 'e' || s.src[k-1] == 'E' || s.src[k-1] == 'p' || s.src[k-1] == 'P'):
+ k++
+ case c == '\'' && s.fam == famC && k+1 < n && isAlnum(s.src[k+1]):
+ k++
+ default:
+ return k
+ }
+ }
+ return k
+}
+
+// esc returns the offset after the escape sequence whose backslash is at k:
+// two bytes, or three for a backslash before CRLF (a line continuation).
+func (s *scanner) esc(k int) int {
+ if s.at(k+1, "\r\n") {
+ return k + 3
+ }
+ return k + 2
+}
+
+// code lexes src from i as code. It returns the offset of the first
+// unmatched closer (')' or '}', when closer is non-zero) or len(src). With
+// fmtStop, a top-level ':' (and, for Python, a '!' conversion) also stops it:
+// the start of a format specifier inside an interpolation hole. PHP stops at
+// `?>` as well.
+func (s *scanner) code(i int, closer byte, fmtStop bool) int {
+ n := len(s.src)
+ depth := 0
+ if s.fam == famJS {
+ s.regexOK = true
+ }
+ for i < n {
+ c := s.src[i]
+ switch c {
+ case ' ', '\t', '\n', '\r', '\f', '\v':
+ i++
+ continue
+ case '(', '[', '{':
+ depth++
+ i++
+ s.regexOK = true
+ continue
+ case ')', ']', '}':
+ if depth == 0 && closer != 0 && c == closer {
+ return i
+ }
+ if depth > 0 {
+ depth--
+ }
+ i++
+ s.regexOK = c == '}'
+ continue
+ case ':':
+ if fmtStop && depth == 0 && !s.at(i+1, ":") && !s.at(i-1, ":") {
+ return i
+ }
+ case '!':
+ if fmtStop && depth == 0 && s.fam == famPython && !s.at(i+1, "=") {
+ return i
+ }
+ }
+ if isIdentStart(c) || c == '$' && s.fam == famJS {
+ k := i + 1
+ for k < n && (isIdentByte(s.src[k]) || s.src[k] == '$' && s.fam == famJS) {
+ k++
+ }
+ if j, ok := s.prefixed(i, k); ok {
+ i = j
+ s.regexOK = false
+ continue
+ }
+ if s.fam == famJS {
+ s.regexOK = jsRegexKeyword(s.src[i:k])
+ }
+ i = k
+ continue
+ }
+ if isDigit(c) {
+ i = s.number(i)
+ s.regexOK = false
+ continue
+ }
+ j := s.token(i)
+ if j < 0 {
+ return i // PHP `?>`
+ }
+ if j > i {
+ i = j
+ s.regexOK = false
+ continue
+ }
+ // Plain punctuation.
+ if s.fam == famJS {
+ s.regexOK = jsRegexAfter(c)
+ if c == '!' && i > 0 && (isIdentByte(s.src[i-1]) || s.src[i-1] == ')' || s.src[i-1] == ']') {
+ s.regexOK = false // TypeScript non-null assertion `x! / 2`
+ }
+ }
+ i++
+ }
+ return n
+}
+
+func jsRegexAfter(c byte) bool {
+ switch c {
+ case ',', '=', ':', '!', '&', '|', '?', ';', '+', '-', '*', '%', '>', '~', '^':
+ return true
+ }
+ return false
+}
+
+func jsRegexKeyword(w string) bool {
+ switch w {
+ case "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
+ "throw", "case", "do", "else", "yield", "await":
+ return true
+ }
+ return false
+}
+
+// prefixed handles an identifier src[i:k] that may be a string prefix. It
+// returns the offset after the literal and true when one was consumed.
+func (s *scanner) prefixed(i, k int) (int, bool) {
+ if k >= len(s.src) {
+ return 0, false
+ }
+ next := s.src[k]
+ w := s.src[i:k]
+ switch s.fam {
+ case famC:
+ if next == '"' || next == '\'' {
+ switch w {
+ case "L", "u", "U", "u8":
+ return s.quoted(i, k, next, true, false), true
+ case "R", "LR", "uR", "UR", "u8R":
+ if next == '"' && s.lang != "c" {
+ if j, ok := s.cppRaw(i, k); ok {
+ return j, true
+ }
+ }
+ }
+ }
+ case famRust:
+ switch w {
+ case "b":
+ if next == '\'' {
+ return s.quoted(i, k, '\'', true, false), true
+ }
+ if next == '"' {
+ return s.quoted(i, k, '"', true, true), true
+ }
+ case "c":
+ if next == '"' {
+ return s.quoted(i, k, '"', true, true), true
+ }
+ case "r", "br", "cr":
+ if next == '"' || next == '#' {
+ return s.rustRaw(i, k)
+ }
+ }
+ case famPython:
+ if (next == '"' || next == '\'') && len(w) <= 2 {
+ raw, fmt := false, false
+ for j := 0; j < len(w); j++ {
+ switch w[j] | 0x20 {
+ case 'r':
+ raw = true
+ case 'f', 't':
+ fmt = true
+ case 'b', 'u':
+ default:
+ return 0, false
+ }
+ }
+ if len(w) == 2 && w[0]|0x20 == w[1]|0x20 {
+ return 0, false
+ }
+ return s.pyString(i, k, raw, fmt), true
+ }
+ }
+ return 0, false
+}
+
+// token lexes a comment or literal starting at a non-identifier byte i and
+// returns the offset after it, i when src[i] is plain punctuation, or -1 for
+// PHP's `?>`.
+func (s *scanner) token(i int) int {
+ c := s.src[i]
+ next := s.byteAt(i + 1)
+ if c == '/' && s.fam != famPython && s.fam != famCOBOL && s.fam != famJCL {
+ if next == '/' {
+ if s.fam == famPHP {
+ return s.phpLineComment(i)
+ }
+ return s.lineComment(i, s.fam == famC)
+ }
+ if next == '*' {
+ nested := s.fam == famRust || s.fam == famKotlin || s.fam == famSwift
+ return s.blockComment(i, nested)
+ }
+ }
+ switch s.fam {
+ case famC:
+ switch c {
+ case '"', '\'':
+ return s.quoted(i, i, c, true, false)
+ case '@':
+ if next == '"' && s.lang == "objc" {
+ return s.quoted(i, i+1, '"', true, false)
+ }
+ case '#':
+ if s.atLineStart(i) {
+ return s.cDirective(i)
+ }
+ }
+ case famJava:
+ switch c {
+ case '"':
+ if s.at(i, `"""`) {
+ return s.javaTextBlock(i)
+ }
+ return s.quoted(i, i, c, true, false)
+ case '\'':
+ return s.quoted(i, i, c, true, false)
+ }
+ case famGo:
+ switch c {
+ case '"', '\'':
+ return s.quoted(i, i, c, true, false)
+ case '`':
+ return s.quoted(i, i, c, false, true)
+ }
+ case famRust:
+ switch c {
+ case '"':
+ return s.quoted(i, i, c, true, true)
+ case '\'':
+ return s.rustQuote(i)
+ }
+ case famJS:
+ switch c {
+ case '"', '\'':
+ // A JSX attribute value (`title="..."`) may span lines; a
+ // plain JavaScript string may not.
+ return s.quoted(i, i, c, true, s.prevByte(i) == '=')
+ case '`':
+ return s.jsTemplate(i)
+ case '#':
+ if i == 0 && next == '!' {
+ return s.lineComment(i, false)
+ }
+ case '/':
+ // `{expr} />` closes a JSX tag; `(/>/g)` is a regex.
+ if s.regexOK && !(next == '>' && s.prevByte(i) == '}') {
+ return s.jsRegex(i)
+ }
+ }
+ case famPython:
+ switch c {
+ case '#':
+ return s.lineComment(i, false)
+ case '"', '\'':
+ return s.pyString(i, i, false, false)
+ }
+ case famPHP:
+ switch c {
+ case '#':
+ if next != '[' {
+ return s.phpLineComment(i)
+ }
+ case '\'':
+ return s.quoted(i, i, c, true, true)
+ case '"', '`':
+ return s.phpInterp(i, i+1, c)
+ case '<':
+ if s.at(i, "<<<") {
+ if j, ok := s.phpHeredoc(i); ok {
+ return j
+ }
+ return i + 3
+ }
+ case '?':
+ if next == '>' {
+ return -1
+ }
+ }
+ case famCSharp:
+ return s.csharpToken(i)
+ case famKotlin:
+ switch c {
+ case '`':
+ return s.backtickName(i)
+ case '"':
+ return s.kotlinString(i)
+ case '\'':
+ return s.quoted(i, i, c, true, false)
+ }
+ case famSwift:
+ switch c {
+ case '`':
+ return s.backtickName(i)
+ case '"':
+ return s.swiftString(i, i, 0)
+ case '#':
+ h := i
+ for h < len(s.src) && s.src[h] == '#' {
+ h++
+ }
+ if h < len(s.src) && s.src[h] == '"' {
+ return s.swiftString(i, h, h-i)
+ }
+ return h
+ }
+ }
+ return i
+}
+
+// ---- C family ----
+
+// cDirective handles a preprocessor line starting at i. `#error` and
+// `#warning` take free text (apostrophes included), so their line is skipped
+// as code apart from comments. Other directives are lexed as code.
+func (s *scanner) cDirective(i int) int {
+ k := i + 1
+ for k < len(s.src) && (s.src[k] == ' ' || s.src[k] == '\t') {
+ k++
+ }
+ w := k
+ for w < len(s.src) && isIdentByte(s.src[w]) {
+ w++
+ }
+ switch s.src[k:w] {
+ case "error", "warning":
+ return s.freeTextLine(w, true)
+ }
+ return i + 1
+}
+
+// freeTextLine skips text from i to the end of the line (with backslash
+// continuation when cont) treating only `//` and `/*` as special.
+func (s *scanner) freeTextLine(i int, cont bool) int {
+ n := len(s.src)
+ for i < n {
+ c := s.src[i]
+ if c == '\n' {
+ j := i - 1
+ if j >= 0 && s.src[j] == '\r' {
+ j--
+ }
+ if cont && j >= 0 && s.src[j] == '\\' {
+ i++
+ continue
+ }
+ return i
+ }
+ if c == '/' && i+1 < n && s.src[i+1] == '/' {
+ return s.lineComment(i, cont)
+ }
+ if c == '/' && i+1 < n && s.src[i+1] == '*' {
+ i = s.blockComment(i, false)
+ continue
+ }
+ i++
+ }
+ return n
+}
+
+// cppRaw lexes a C++ raw string `R"delim( ... )delim"` whose quote is at q.
+func (s *scanner) cppRaw(start, q int) (int, bool) {
+ n := len(s.src)
+ k := q + 1
+ for k < n && k-q <= 17 {
+ c := s.src[k]
+ if c == '(' {
+ break
+ }
+ if c == ' ' || c == ')' || c == '\\' || c == '\t' || c == '\n' || c == '"' {
+ return 0, false
+ }
+ k++
+ }
+ if k >= n || s.src[k] != '(' {
+ return 0, false
+ }
+ delim := s.src[q+1 : k]
+ end := n
+ for p := k + 1; p < n; p++ {
+ if s.src[p] == ')' && s.at(p+1, delim) && s.byteAt(p+1+len(delim)) == '"' {
+ end = p + 2 + len(delim)
+ break
+ }
+ }
+ s.lit(start, end)
+ return end, true
+}
+
+// ---- Java ----
+
+func (s *scanner) javaTextBlock(i int) int {
+ n := len(s.src)
+ k := i + 3
+ for k < n {
+ if s.src[k] == '\\' {
+ k = s.esc(k)
+ continue
+ }
+ if s.at(k, `"""`) {
+ k += 3
+ break
+ }
+ k++
+ }
+ if k > n {
+ k = n
+ }
+ s.lit(i, k)
+ return k
+}
+
+// ---- Rust ----
+
+// rustQuote tells a char literal (`'a'`, `'\n'`, `'é'`, `'"'`) from a
+// lifetime or label (`'a`, `'static`, `'_`), which is code.
+func (s *scanner) rustQuote(i int) int {
+ n := len(s.src)
+ if i+1 >= n {
+ return i + 1
+ }
+ if s.src[i+1] == '\\' {
+ return s.quoted(i, i, '\'', true, false)
+ }
+ _, size := utf8.DecodeRuneInString(s.src[i+1:])
+ if k := i + 1 + size; k < n && s.src[k] == '\'' {
+ s.lit(i, k+1)
+ return k + 1
+ }
+ return i + 1 // lifetime: the name after it lexes as an identifier
+}
+
+// rustRaw lexes `r"..."`, `r#"..."#`, `br##"..."##`; the prefix spans
+// src[start:k]. `r#ident` (a raw identifier) is not a string.
+func (s *scanner) rustRaw(start, k int) (int, bool) {
+ h := k
+ for h < len(s.src) && s.src[h] == '#' {
+ h++
+ }
+ if h >= len(s.src) || s.src[h] != '"' {
+ return 0, false
+ }
+ hashes := h - k
+ end := len(s.src)
+ for q := h + 1; q < len(s.src); q++ {
+ if s.src[q] != '"' {
+ continue
+ }
+ e := q + 1
+ for e < len(s.src) && e-q-1 < hashes && s.src[e] == '#' {
+ e++
+ }
+ if e-q-1 == hashes {
+ end = e
+ break
+ }
+ }
+ s.lit(start, end)
+ return end, true
+}
+
+// ---- JavaScript / TypeScript ----
+
+func (s *scanner) jsTemplate(i int) int {
+ n := len(s.src)
+ seg := i
+ k := i + 1
+ for k < n {
+ c := s.src[k]
+ if c == '\\' {
+ k = s.esc(k)
+ continue
+ }
+ if c == '`' {
+ k++
+ s.lit(seg, k)
+ return k
+ }
+ if c == '$' && k+1 < n && s.src[k+1] == '{' {
+ s.lit(seg, k)
+ k = s.hole(k+2, '}')
+ seg = k
+ continue
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// hole lexes an interpolation hole's code starting at i up to closer and
+// returns the offset after the closer.
+func (s *scanner) hole(i int, closer byte) int {
+ saved := s.regexOK
+ k := s.code(i, closer, false)
+ s.regexOK = saved
+ if k < len(s.src) {
+ k++
+ }
+ return k
+}
+
+// jsRegex lexes a regex literal at i, or returns i when the line ends before
+// a closing slash (then the '/' was a division after all).
+func (s *scanner) jsRegex(i int) int {
+ n := len(s.src)
+ k := i + 1
+ inClass := false
+ for k < n {
+ c := s.src[k]
+ switch {
+ case c == '\\':
+ k = s.esc(k)
+ continue
+ case c == '\n' || c == '\r':
+ return i
+ case c == '[':
+ inClass = true
+ case c == ']':
+ inClass = false
+ case c == '/' && !inClass:
+ k++
+ for k < n && isIdentByte(s.src[k]) {
+ k++
+ }
+ s.lit(i, k)
+ return k
+ }
+ k++
+ }
+ return i
+}
+
+// ---- Python ----
+
+// pyString lexes a Python string whose prefix starts at start and whose
+// opening quote is at q.
+func (s *scanner) pyString(start, q int, raw, fmtStr bool) int {
+ n := len(s.src)
+ qc := s.src[q]
+ triple := q+2 < n && s.src[q+1] == qc && s.src[q+2] == qc
+ k := q + 1
+ if triple {
+ k = q + 3
+ }
+ seg := start
+ for k < n {
+ c := s.src[k]
+ switch {
+ case c == '\\':
+ if fmtStr && s.byteAt(k+1) == '{' && raw {
+ k++ // rf"\{x}": the brace still opens a hole
+ continue
+ }
+ if fmtStr && !raw && s.at(k+1, "N{") {
+ // \N{NAME} is an escape, not a hole.
+ if e := strings.IndexByte(s.src[k:], '}'); e >= 0 {
+ k += e + 1
+ continue
+ }
+ }
+ k = s.esc(k)
+ continue
+ case c == qc:
+ if !triple {
+ k++
+ s.lit(seg, k)
+ return k
+ }
+ if k+2 < n && s.src[k+1] == qc && s.src[k+2] == qc {
+ k += 3
+ s.lit(seg, k)
+ return k
+ }
+ case c == '\n' && !triple:
+ s.lit(seg, k)
+ return k
+ case fmtStr && c == '{':
+ if k+1 < n && s.src[k+1] == '{' {
+ k += 2
+ continue
+ }
+ s.lit(seg, k)
+ k = s.fmtHole(k + 1)
+ seg = k
+ continue
+ }
+ k++
+ }
+ if k > n {
+ k = n
+ }
+ s.lit(seg, k)
+ return k
+}
+
+// fmtHole lexes a Python f-string or C# interpolation hole whose code starts
+// at i: the expression, an optional Python `!r` conversion, and an optional
+// format specifier (masked as literal text, nested holes kept). It returns
+// the offset after the closing '}'.
+func (s *scanner) fmtHole(i int) int {
+ n := len(s.src)
+ k := s.code(i, '}', true)
+ if k < n && s.src[k] == '!' {
+ k++
+ for k < n && isIdentByte(s.src[k]) {
+ k++
+ }
+ }
+ if k < n && s.src[k] == ':' {
+ k = s.fmtSpec(k)
+ }
+ if k < n && s.src[k] == '}' {
+ k++
+ }
+ return k
+}
+
+// fmtSpec masks a format specifier starting at its ':' up to the hole's
+// closing '}', which it returns the offset of.
+func (s *scanner) fmtSpec(i int) int {
+ n := len(s.src)
+ seg := i
+ k := i
+ for k < n {
+ c := s.src[k]
+ if c == '}' {
+ break
+ }
+ if c == '{' && s.fam == famPython {
+ s.lit(seg, k)
+ k = s.fmtHole(k + 1)
+ seg = k
+ continue
+ }
+ if c == '\n' && s.fam == famCSharp {
+ break
+ }
+ k++
+ }
+ s.lit(seg, k)
+ return k
+}
+
+// ---- PHP ----
+
+// phpHTML masks inline HTML from i up to the next open tag (`` close tag.
+func (s *scanner) phpLineComment(i int) int {
+ e := s.eol(i)
+ if t := strings.Index(s.src[i:e], "?>"); t >= 0 {
+ e = i + t
+ }
+ s.blank(i, e)
+ return e
+}
+
+// phpInterp lexes a double-quoted or backtick PHP string whose quote is at
+// q-1 and whose text starts at q, keeping `{$...}`, `${...}` and simple
+// variable interpolations as code.
+func (s *scanner) phpInterp(start, k int, q byte) int {
+ n := len(s.src)
+ seg := start
+ for k < n {
+ c := s.src[k]
+ if c == '\\' {
+ k = s.esc(k)
+ continue
+ }
+ if c == q {
+ k++
+ s.lit(seg, k)
+ return k
+ }
+ if j := s.phpVar(k); j > k {
+ s.lit(seg, k)
+ seg, k = j, j
+ continue
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// phpVar recognises an interpolation at k inside a PHP string: `{$expr}`,
+// `${expr}`, `$name`, `$name->prop`, `$name?->prop` or `$name[key]`. It
+// returns the offset after it, or k when there is none.
+func (s *scanner) phpVar(k int) int {
+ n := len(s.src)
+ if s.at(k, "{$") {
+ return s.hole(k+1, '}')
+ }
+ if s.at(k, "${") {
+ return s.hole(k+2, '}')
+ }
+ if s.src[k] != '$' || k+1 >= n || !isIdentStart(s.src[k+1]) {
+ return k
+ }
+ j := k + 2
+ for j < n && isIdentByte(s.src[j]) {
+ j++
+ }
+ switch {
+ case s.at(j, "->") && j+2 < n && isIdentStart(s.src[j+2]):
+ j += 3
+ for j < n && isIdentByte(s.src[j]) {
+ j++
+ }
+ case s.at(j, "?->") && j+3 < n && isIdentStart(s.src[j+3]):
+ j += 4
+ for j < n && isIdentByte(s.src[j]) {
+ j++
+ }
+ case s.at(j, "["):
+ e := j + 1
+ for e < n && s.src[e] != ']' && s.src[e] != '\n' && s.src[e] != '"' {
+ e++
+ }
+ if e < n && s.src[e] == ']' {
+ j = e + 1
+ }
+ }
+ return j
+}
+
+// phpHeredoc lexes `<<= n || s.src[k] != quote {
+ return 0, false
+ }
+ k++
+ }
+ if k < n && s.src[k] == '\r' {
+ k++
+ }
+ if k >= n || s.src[k] != '\n' {
+ return 0, false
+ }
+ interp := quote != '\''
+ seg := i
+ k++
+ lineStart := true
+ for k < n {
+ if lineStart {
+ j := k
+ for j < n && (s.src[j] == ' ' || s.src[j] == '\t') {
+ j++
+ }
+ if s.at(j, id) && !isIdentByte(s.byteAt(j+len(id))) {
+ end := j + len(id)
+ s.lit(seg, end)
+ return end, true
+ }
+ lineStart = false
+ }
+ c := s.src[k]
+ if c == '\n' {
+ lineStart = true
+ k++
+ continue
+ }
+ if interp {
+ if c == '\\' {
+ k = s.esc(k)
+ continue
+ }
+ if j := s.phpVar(k); j > k {
+ s.lit(seg, k)
+ seg, k = j, j
+ continue
+ }
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n, true
+}
+
+// ---- C# ----
+
+func (s *scanner) csharpToken(i int) int {
+ n := len(s.src)
+ c := s.src[i]
+ switch c {
+ case '\'':
+ return s.quoted(i, i, c, true, false)
+ case '#':
+ if s.atLineStart(i) {
+ k := i + 1
+ for k < n && (s.src[k] == ' ' || s.src[k] == '\t') {
+ k++
+ }
+ w := k
+ for w < n && isIdentByte(s.src[w]) {
+ w++
+ }
+ switch s.src[k:w] {
+ case "region", "endregion", "error", "warning", "pragma", "line", "nullable":
+ return s.freeTextLine(w, false)
+ }
+ }
+ return i
+ case '"', '@', '$':
+ default:
+ return i
+ }
+ // String prefixes: `$`* and `@` in either order, then quotes.
+ k := i
+ dollars, verbatim := 0, false
+ for k < n {
+ if s.src[k] == '$' {
+ dollars++
+ } else if s.src[k] == '@' && !verbatim {
+ verbatim = true
+ } else {
+ break
+ }
+ k++
+ }
+ if k >= n || s.src[k] != '"' {
+ return i
+ }
+ q := k
+ for q < n && s.src[q] == '"' {
+ q++
+ }
+ quotes := q - k
+ if quotes >= 3 && !verbatim {
+ return s.csharpRaw(i, k, quotes, dollars)
+ }
+ if verbatim {
+ if dollars == 0 {
+ return s.doubled(i, k, '"', false)
+ }
+ return s.csharpInterp(i, k+1, true)
+ }
+ if dollars > 0 {
+ return s.csharpInterp(i, k+1, false)
+ }
+ return s.quoted(i, k, '"', true, false)
+}
+
+// csharpInterp lexes `$"..."` (and `$@"..."` when verbatim) from the text
+// start k, keeping `{expr}` holes as code.
+func (s *scanner) csharpInterp(start, k int, verbatim bool) int {
+ n := len(s.src)
+ seg := start
+ for k < n {
+ c := s.src[k]
+ switch {
+ case c == '\\' && !verbatim:
+ k = s.esc(k)
+ continue
+ case c == '"':
+ if verbatim && k+1 < n && s.src[k+1] == '"' {
+ k += 2
+ continue
+ }
+ k++
+ s.lit(seg, k)
+ return k
+ case c == '\n' && !verbatim:
+ s.lit(seg, k)
+ return k
+ case c == '{' || c == '}':
+ if k+1 < n && s.src[k+1] == c {
+ k += 2
+ continue
+ }
+ if c == '{' {
+ s.lit(seg, k)
+ k = s.fmtHole(k + 1)
+ seg = k
+ continue
+ }
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// csharpRaw lexes a raw string literal of quotes quote characters starting
+// at k (prefix from start). With dollars > 0 it is interpolated: a run of
+// `dollars` braces opens a hole, fewer are literal text.
+func (s *scanner) csharpRaw(start, k, quotes, dollars int) int {
+ n := len(s.src)
+ seg := start
+ k += quotes
+ for k < n {
+ c := s.src[k]
+ if c == '"' {
+ r := k
+ for r < n && s.src[r] == '"' {
+ r++
+ }
+ if r-k >= quotes {
+ s.lit(seg, r)
+ return r
+ }
+ k = r
+ continue
+ }
+ if c == '{' && dollars > 0 {
+ r := k
+ for r < n && s.src[r] == '{' {
+ r++
+ }
+ if r-k < dollars {
+ k = r
+ continue
+ }
+ open := r - dollars
+ s.lit(seg, open)
+ e := s.code(r, '}', true)
+ if e < n && s.src[e] == ':' {
+ e = s.fmtSpec(e)
+ }
+ for j := 0; j < dollars && e < n && s.src[e] == '}'; j++ {
+ e++
+ }
+ seg, k = e, e
+ continue
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// ---- Kotlin ----
+
+func (s *scanner) kotlinString(i int) int {
+ n := len(s.src)
+ raw := s.at(i, `"""`)
+ k := i + 1
+ if raw {
+ k = i + 3
+ }
+ seg := i
+ for k < n {
+ c := s.src[k]
+ switch {
+ case c == '\\' && !raw:
+ k = s.esc(k)
+ continue
+ case c == '"':
+ if !raw {
+ k++
+ s.lit(seg, k)
+ return k
+ }
+ if s.at(k, `"""`) {
+ k += 3
+ for k < n && s.src[k] == '"' {
+ k++
+ }
+ s.lit(seg, k)
+ return k
+ }
+ case c == '\n' && !raw:
+ s.lit(seg, k)
+ return k
+ case c == '$' && k+1 < n:
+ if s.src[k+1] == '{' {
+ s.lit(seg, k)
+ k = s.hole(k+2, '}')
+ seg = k
+ continue
+ }
+ if isIdentStart(s.src[k+1]) || s.src[k+1] == '`' {
+ s.lit(seg, k)
+ k += 2
+ for k < n && isIdentByte(s.src[k]) {
+ k++
+ }
+ seg = k
+ continue
+ }
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// ---- Swift ----
+
+// swiftString lexes a Swift string whose prefix starts at start, whose
+// first quote is at q and which has hashes extended delimiters.
+func (s *scanner) swiftString(start, q, hashes int) int {
+ n := len(s.src)
+ multi := s.at(q, `"""`)
+ k := q + 1
+ if multi {
+ k = q + 3
+ }
+ seg := start
+ for k < n {
+ c := s.src[k]
+ switch {
+ case c == '\\':
+ j := k + 1
+ h := 0
+ for j < n && s.src[j] == '#' && h < hashes {
+ j++
+ h++
+ }
+ if h < hashes {
+ k++
+ continue
+ }
+ if j < n && s.src[j] == '(' {
+ s.lit(seg, k)
+ k = s.hole(j+1, ')')
+ seg = k
+ continue
+ }
+ k = j + 1
+ continue
+ case c == '"':
+ if multi && !s.at(k, `"""`) {
+ k++
+ continue
+ }
+ e := k + 1
+ if multi {
+ e = k + 3
+ }
+ h := 0
+ for e < n && s.src[e] == '#' && h < hashes {
+ e++
+ h++
+ }
+ if h == hashes {
+ s.lit(seg, e)
+ return e
+ }
+ k++
+ continue
+ case c == '\n' && !multi:
+ s.lit(seg, k)
+ return k
+ }
+ k++
+ }
+ s.lit(seg, n)
+ return n
+}
+
+// ---- COBOL ----
+
+// cobol masks COBOL source line by line. Fixed format (the default): a `*`
+// or `/` in column 7 makes the line a comment (columns 7-72 masked; the
+// sequence areas are left as-is). `*>` starts an inline comment in either
+// format. `>>SOURCE FORMAT FREE` switches to free format.
+func (s *scanner) cobol() {
+ n := len(s.src)
+ free := false
+ for ls := 0; ls < n; {
+ le := s.eol(ls)
+ line := s.src[ls:le]
+ if t := strings.TrimSpace(line); strings.HasPrefix(t, ">>") || strings.HasPrefix(t, "$SET") {
+ u := strings.ToUpper(t)
+ if strings.Contains(u, "FREE") {
+ free = true
+ } else if strings.Contains(u, "FIXED") {
+ free = false
+ }
+ }
+ start := ls
+ if !free {
+ if len(line) > 6 && (line[6] == '*' || line[6] == '/') {
+ end := le
+ if end > ls+72 {
+ end = ls + 72
+ }
+ s.blank(ls+6, end)
+ ls = le + 1
+ continue
+ }
+ start = ls + 7
+ if start > le {
+ start = le
+ }
+ }
+ for k := start; k < le; {
+ c := s.src[k]
+ switch {
+ case c == '*' && k+1 < le && s.src[k+1] == '>':
+ end := le
+ if !free && end > ls+72 {
+ end = ls + 72 // leave the sequence area
+ }
+ s.blank(k, end)
+ k = le
+ case c == '\'' || c == '"':
+ k = s.doubled(k, k, c, true)
+ default:
+ k++
+ }
+ }
+ ls = le + 1
+ }
+}
+
+// ---- JCL ----
+
+// jcl masks `//*` comment lines and quoted strings (`”` escapes a quote).
+func (s *scanner) jcl() {
+ n := len(s.src)
+ for ls := 0; ls < n; {
+ le := s.eol(ls)
+ if s.at(ls, "//*") {
+ s.blank(ls, le)
+ ls = le + 1
+ continue
+ }
+ for k := ls; k < le; {
+ if s.src[k] == '\'' {
+ k = s.doubled(k, k, '\'', true)
+ } else {
+ k++
+ }
+ }
+ ls = le + 1
+ }
+}
diff --git a/textmask/textmask_test.go b/textmask/textmask_test.go
new file mode 100644
index 0000000..492bad8
--- /dev/null
+++ b/textmask/textmask_test.go
@@ -0,0 +1,491 @@
+package textmask
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// maskCase lists substrings of src that Mask must keep verbatim (code) and
+// substrings it must blank entirely (comment or literal text). Each
+// substring is located by its first occurrence in src.
+type maskCase struct {
+ name string
+ lang string
+ src string
+ keep []string
+ gone []string
+}
+
+func checkCase(t *testing.T, fn func(lang, src string) string, tc maskCase) {
+ t.Helper()
+ got := fn(tc.lang, tc.src)
+ if len(got) != len(tc.src) {
+ t.Fatalf("%s: length %d, want %d", tc.name, len(got), len(tc.src))
+ }
+ for _, k := range tc.keep {
+ i := strings.Index(tc.src, k)
+ if i < 0 {
+ t.Fatalf("%s: keep %q not in src", tc.name, k)
+ }
+ if got[i:i+len(k)] != k {
+ t.Errorf("%s: want %q kept, got %q\n%s", tc.name, k, got[i:i+len(k)], got)
+ }
+ }
+ for _, g := range tc.gone {
+ i := strings.Index(tc.src, g)
+ if i < 0 {
+ t.Fatalf("%s: gone %q not in src", tc.name, g)
+ }
+ if strings.Trim(got[i:i+len(g)], " \r\n") != "" {
+ t.Errorf("%s: want %q masked, got %q\n%s", tc.name, g, got[i:i+len(g)], got)
+ }
+ }
+}
+
+var maskCases = []maskCase{
+ // ---- Python ----
+ {name: "py single-quoted string is not a lifetime", lang: "python",
+ src: "log.info('starting up')\nself.client = Foo()\nx = 'a'\n",
+ keep: []string{"log.info(", "self.client = Foo()", "x = "},
+ gone: []string{"'starting up'", "'a'"}},
+ {name: "py docstring with apostrophe", lang: "python",
+ src: "def f():\n '''Don't do this: self.cache = Redis()'''\n return g()\n",
+ keep: []string{"def f():", "return g()"},
+ gone: []string{"'''Don't do this: self.cache = Redis()'''"}},
+ {name: "py hash in string is not a comment", lang: "python",
+ src: "a = \"x # y\"; b = c # real\n",
+ keep: []string{"a = ", "; b = c"},
+ gone: []string{"\"x # y\"", "# real"}},
+ {name: "py prefixes", lang: "python",
+ src: "a = rb'\\x' + BR\"y\" + u'z' + Rb'''w''' + F'{v}'\n",
+ keep: []string{"a = ", " + ", "{v}"},
+ gone: []string{"rb'\\x'", "BR\"y\"", "u'z'", "Rb'''w'''", "F'"}},
+ {name: "py raw string escaped quote", lang: "python",
+ src: "z = r\"\\\"\" + tail()\n",
+ keep: []string{"+ tail()"},
+ gone: []string{"r\"\\\"\""}},
+ {name: "py f-string holes", lang: "python",
+ src: "m = f\"v={label(3)} {{lit}} {x!r} {y:>10} {z:{w}}\"\n",
+ keep: []string{"{label(3)}", "{x!r", "{w}"},
+ gone: []string{"v=", "{{lit}}", ">10"}},
+ {name: "py f-string nested quotes (3.12)", lang: "python",
+ src: "s = f\"{\", \".join(songs)}\"; t = f'{d['k']}'\n",
+ keep: []string{".join(songs)}", "; t = ", "{d[", "]}"},
+ gone: []string{"\", \"", "'k'"}},
+ {name: "py t-string", lang: "python",
+ src: "t = t\"hi {name}\"\n",
+ keep: []string{"{name}"},
+ gone: []string{"t\"hi "}},
+ {name: "py backslash continuation in single-quoted string", lang: "python",
+ src: "c = 'one \\\ntwo'\nd = 1\n",
+ keep: []string{"d = 1"},
+ gone: []string{"'one \\", "two'"}},
+ {name: "py \\N{} is an escape not a hole", lang: "python",
+ src: "n = f\"\\N{EM DASH}{v}\"\n",
+ keep: []string{"{v}"},
+ gone: []string{"\\N{EM DASH}"}},
+ {name: "py triple-quoted f-string hole across lines", lang: "python",
+ src: "m = f'''\n{compute(1,\n 2)}\n'''\n",
+ keep: []string{"{compute(1,", "2)}"}},
+
+ // ---- Rust ----
+ {name: "rust raw string with hashes", lang: "rust",
+ src: "let f = r#\"\nlet client = Bar::new();\n\"#;\nlet g = 1;\n",
+ keep: []string{"let f = ", "let g = 1;"},
+ gone: []string{"let client = Bar::new();", "r#\"", "\"#"}},
+ {name: "rust lifetimes vs char literals", lang: "rust",
+ src: "fn f<'a>(x: &'a str) -> char { '\"' }\nfn g() -> &'static str { \"s\" }\n",
+ keep: []string{"fn f<'a>(x: &'a str) -> char {", "fn g() -> &'static str {"},
+ gone: []string{"'\"'", "\"s\""}},
+ {name: "rust char forms", lang: "rust",
+ src: "let a = ['a', '\\n', '\\u{1F600}', 'é', b'x', b'\\''];\nlet r#type = 1; 'l: loop { break 'l; }\n",
+ keep: []string{"let a = [", "let r#type = 1; 'l: loop { break 'l; }"},
+ gone: []string{"'a'", "'\\n'", "'\\u{1F600}'", "'é'", "b'x'", "b'\\''"}},
+ {name: "rust nested block comment", lang: "rust",
+ src: "/* a /* b */ fn phantom() {} */ fn real() {}\n",
+ keep: []string{"fn real() {}"},
+ gone: []string{"fn phantom() {}"}},
+ {name: "rust byte, raw byte, c strings", lang: "rust",
+ src: "let x = (b\"by\\\"tes\", br##\"r\"#b\"##, c\"cs\", \"multi\nline\");\n",
+ keep: []string{"let x = ("},
+ gone: []string{"b\"by\\\"tes\"", "br##\"r\"#b\"##", "c\"cs\"", "\"multi", "line\""}},
+ {name: "rust attributes are code", lang: "rust",
+ src: "#![allow(x)]\n#[derive(Debug)]\nstruct S;\n",
+ keep: []string{"#![allow(x)]", "#[derive(Debug)]"}},
+
+ // ---- Go ----
+ {name: "go raw string has no escapes", lang: "go",
+ src: "p = strings.ReplaceAll(p, `\\`, \"/\")\nq := 1\n",
+ keep: []string{"p = strings.ReplaceAll(p, ", "q := 1"},
+ gone: []string{"`\\`", "\"/\""}},
+ {name: "go runes and multi-line raw", lang: "go",
+ src: "r := '\\''\nraw := `\nfunc phantom() {}\n`\nx := r\n",
+ keep: []string{"r := ", "x := r"},
+ gone: []string{"'\\''", "func phantom() {}"}},
+
+ // ---- C / C++ / ObjC ----
+ {name: "cpp digit separators", lang: "cpp",
+ src: "constexpr int k = 1'000;\nusing namespace foo;\nauto h = 0xFF'FF;\n",
+ keep: []string{"constexpr int k = 1'000;", "using namespace foo;", "auto h = 0xFF'FF;"}},
+ {name: "cpp raw string spanning lines", lang: "cpp",
+ src: "auto s = R\"(\nint phantom(int x) {\n)\";\nint real();\n",
+ keep: []string{"auto s = ", "int real();"},
+ gone: []string{"int phantom(int x) {", "R\"("}},
+ {name: "cpp raw string custom delimiter and prefixes", lang: "cpp",
+ src: "auto a = R\"xy(has )\" inside)xy\"; auto b = u8R\"(u)\"; auto c = LR\"(l)\"; auto d = uR\"(x)\"; auto e = UR\"d(y)d\";\n",
+ keep: []string{"auto a = ", "; auto b = ", "; auto c = ", "; auto d = ", "; auto e = "},
+ gone: []string{"R\"xy(has )\" inside)xy\"", "u8R\"(u)\"", "LR\"(l)\"", "uR\"(x)\"", "UR\"d(y)d\""}},
+ {name: "c error directive apostrophe", lang: "c",
+ src: "#error don't build\nint after = 1;\n",
+ keep: []string{"#error don't build", "int after = 1;"}},
+ {name: "c line comment continuation", lang: "c",
+ src: "// comment \\\n int phantom(int x);\nint real;\n",
+ keep: []string{"int real;"},
+ gone: []string{"int phantom(int x);"}},
+ {name: "c apostrophe in comment", lang: "c",
+ src: "/* don't */ int a; // isn't\nint b;\n",
+ keep: []string{"int a;", "int b;"},
+ gone: []string{"don't", "isn't"}},
+ {name: "c include and define", lang: "c",
+ src: "#include \n#include \"x.h\"\n#define N 'n'\n",
+ keep: []string{"#include ", "#include ", "#define N "},
+ gone: []string{"\"x.h\"", "'n'"}},
+ {name: "c string continuation", lang: "c",
+ src: "const char *s = \"a \\\nb\"; int z;\n",
+ keep: []string{"int z;"},
+ gone: []string{"\"a \\", "b\""}},
+ {name: "objc at-string", lang: "objc",
+ src: "NSString *s = @\"hi \\\" // no\"; int x;\n",
+ keep: []string{"NSString *s = ", "; int x;"},
+ gone: []string{"@\"hi \\\" // no\""}},
+
+ // ---- Java ----
+ {name: "java text block", lang: "java",
+ src: "String s = \"\"\"\n one \" two \"\" three \\\"\"\"\n class Phantom {}\n \"\"\";\nint x;\n",
+ keep: []string{"String s = ", "int x;"},
+ gone: []string{"class Phantom {}", "one \" two \"\" three \\\"\"\""}},
+ {name: "java chars", lang: "java",
+ src: "char c = '\\''; char d = '\"'; int n = 1;\n",
+ keep: []string{"char c = ", "; char d = ", "; int n = 1;"},
+ gone: []string{"'\\''", "'\"'"}},
+
+ // ---- C# ----
+ {name: "cs attribute string", lang: "csharp",
+ src: "[Description(\"class Y : Fake\")]\nclass X {}\n",
+ keep: []string{"[Description(", ")]", "class X {}"},
+ gone: []string{"class Y : Fake"}},
+ {name: "cs verbatim string ending in backslash", lang: "csharp",
+ src: "string p = @\"C:\\x\\\"; int after;\n",
+ keep: []string{"string p = ", "; int after;"},
+ gone: []string{"@\"C:\\x\\\""}},
+ {name: "cs verbatim doubled quotes multi-line", lang: "csharp",
+ src: "string v = @\"one\n\"\"two\"\"\"; int k;\n",
+ keep: []string{"string v = ", "; int k;"},
+ gone: []string{"@\"one", "\"\"two\"\"\""}},
+ {name: "cs interpolation", lang: "csharp",
+ src: "string s = $\"{a} and {{lit}} {price:N2} {Call(\"x\")}\";\n",
+ keep: []string{"{a}", "{price", "{Call(", ")}"},
+ gone: []string{" and {{lit}} ", "N2", "\"x\""}},
+ {name: "cs verbatim interpolation both orders", lang: "csharp",
+ src: "var a = $@\"C:\\{dir}\\f\"; var b = @$\"{dir}\\x\";\n",
+ keep: []string{"{dir}", "var b = "},
+ gone: []string{"$@\"C:\\", "\\f\"", "\\x\""}},
+ {name: "cs raw strings", lang: "csharp",
+ src: "var r = \"\"\"raw \"q\" text\"\"\"; var m = \"\"\"\"\n has \"\"\" inside\n \"\"\"\"; var i = $$\"\"\"{{Name}} {lit}\"\"\";\n",
+ keep: []string{"var r = ", "; var m = ", "; var i = ", "{{Name}}"},
+ gone: []string{"raw \"q\" text", "has \"\"\" inside", "{lit}"}},
+ {name: "cs region line is code", lang: "csharp",
+ src: "#region Don't parse\nclass X {}\n#endregion\n",
+ keep: []string{"#region Don't parse", "class X {}"}},
+
+ // ---- JavaScript / TypeScript ----
+ {name: "js private field and template", lang: "javascript",
+ src: "this.#tpl = `a ${b} c`;\n",
+ keep: []string{"this.#tpl = ", "${b}"},
+ gone: []string{"`a ", " c`"}},
+ {name: "js regex with quotes", lang: "javascript",
+ src: "const re = /[\"']/g; const x = y;\n",
+ keep: []string{"const re = ", "; const x = y;"},
+ gone: []string{"/[\"']/g"}},
+ {name: "js division is not a regex", lang: "javascript",
+ src: "const r = a / b / c; const s = 'x';\n",
+ keep: []string{"const r = a / b / c; const s = "},
+ gone: []string{"'x'"}},
+ {name: "js nested templates", lang: "javascript",
+ src: "const n = `o ${c ? `i ${b + 1}` : 'no'} e`;\n",
+ keep: []string{"${c ? ", "${b + 1}", " : "},
+ gone: []string{"`o ", "`i ", "'no'", " e`"}},
+ {name: "js hashbang", lang: "javascript",
+ src: "#!/usr/bin/env node\nrun();\n",
+ keep: []string{"run();"},
+ gone: []string{"#!/usr/bin/env node"}},
+ {name: "js regex after keyword", lang: "javascript",
+ src: "return /'/.test(s) && typeof /\"/;\n",
+ keep: []string{"return ", ".test(s) && typeof "},
+ gone: []string{"/'/", "/\"/"}},
+ {name: "ts non-null assertion before division", lang: "typescript",
+ src: "const w = `${h! / 2}px`; const q = 'x';\n",
+ keep: []string{"${h! / 2}", "const q = "},
+ gone: []string{"px`", "'x'"}},
+ {name: "tsx self-closing tag after expression", lang: "tsx",
+ src: "const e = ; const s = 'x';\n",
+ keep: []string{"; const s = "},
+ gone: []string{"'x'"}},
+ {name: "jsx alias", lang: "jsx",
+ src: "const s = 'x'; // c\n",
+ keep: []string{"const s = "},
+ gone: []string{"'x'", "// c"}},
+
+ // ---- PHP ----
+ {name: "php attribute is not a comment", lang: "php",
+ src: "#[Route('/x')] public function act() {} # real comment\n",
+ keep: []string{"#[Route(", ")] public function act() {}"},
+ gone: []string{"'/x'", "# real comment"}},
+ {name: "php interpolation holes", lang: "php",
+ src: "$d = \"r={$this->act(2)} v=$n w=$o->p x=$a[0] y=${z}\";\n",
+ keep: []string{"$d = ", "$this->act(2)", "$n", "$o->p", "$a[0]", "z"},
+ gone: []string{"r=", " v=", " w=", " x=", " y="}},
+ {name: "php single quotes do not interpolate", lang: "php",
+ src: "$s = 'it\\'s {$no} #x'; $t = 1;\n",
+ keep: []string{"$s = ", "; $t = 1;"},
+ gone: []string{"'it\\'s {$no} #x'"}},
+ {name: "php heredoc", lang: "php",
+ src: "$h = <<act(3)} $n\nfunction phantom() {}\nEOT;\n$after = 1;\n",
+ keep: []string{"$h = ", "$this->act(3)", "$n\n", ";\n$after = 1;"},
+ gone: []string{"<<\ndon't parse
\n", "don't parse
"}},
+ {name: "php line comment ends at close tag", lang: "php",
+ src: "echo 1; // c ?>x", "x"}},
+
+ // ---- Kotlin ----
+ {name: "kotlin templates", lang: "kotlin",
+ src: "val v = \"v=${label(3)} and $name \\\"q\\\"\"\n",
+ keep: []string{"val v = ", "${label(3)}", "$name"},
+ gone: []string{"\"v=", " and ", "\\\"q\\\"\""}},
+ {name: "kotlin raw string with templates", lang: "kotlin",
+ src: "val r = \"\"\"\nfun phantom() {}\n${label(4)}\n\"\"\"\nval c = '\\''\n",
+ keep: []string{"val r = ", "${label(4)}", "val c = "},
+ gone: []string{"fun phantom() {}", "'\\''"}},
+ {name: "kotlin backtick names", lang: "kotlin",
+ src: "fun `it doesn't fail`() { val s = \"x\" }\n",
+ keep: []string{"fun `it doesn't fail`() { val s = "},
+ gone: []string{"\"x\""}},
+ {name: "kotlin nested comments", lang: "kotlin",
+ src: "/* a /* b */ fun phantom() */ fun real() {}\n",
+ keep: []string{"fun real() {}"},
+ gone: []string{"fun phantom()"}},
+
+ // ---- Swift ----
+ {name: "swift interpolation", lang: "swift",
+ src: "let v = \"v=\\(label(3)) \\\"q\\\"\"\n",
+ keep: []string{"let v = ", "label(3)"},
+ gone: []string{"\"v=", "\\\"q\\\"\""}},
+ {name: "swift extended delimiters", lang: "swift",
+ src: "let r = #\"raw \"quoted\"\"#; let h = #\"\\#(label(4)) \\(no)\"#; let d = ##\"a \"# b\"##\n",
+ keep: []string{"let r = ", "; let h = ", "label(4)", "; let d = "},
+ gone: []string{"#\"raw \"quoted\"\"#", "\\(no)\"#", "##\"a \"# b\"##"}},
+ {name: "swift multi-line string", lang: "swift",
+ src: "let m = \"\"\"\n func phantom() {}\n \\(label(5))\n \"\"\"\nlet x = 1\n",
+ keep: []string{"let m = ", "label(5)", "let x = 1"},
+ gone: []string{"func phantom() {}"}},
+ {name: "swift apostrophe is code", lang: "swift",
+ src: "let a = b' + 1\n",
+ keep: []string{"let a = b' + 1"}},
+
+ // ---- COBOL / JCL ----
+ {name: "cobol fixed comment and strings", lang: "cobol",
+ src: "000100* COMMENT 'Q\n000200 DISPLAY 'IT''S'. *> INLINE\n000300 STOP RUN.\n",
+ keep: []string{"000100", "000200 DISPLAY ", ".", "000300 STOP RUN."},
+ gone: []string{"* COMMENT 'Q", "'IT''S'", "*> INLINE"}},
+ {name: "cobol free format", lang: "cobol",
+ src: ">>SOURCE FORMAT FREE\n* not a comment\nDISPLAY \"A\"\"B\". *> c\n",
+ keep: []string{"* not a comment", "DISPLAY ", "."},
+ gone: []string{"\"A\"\"B\"", "*> c"}},
+ {name: "jcl", lang: "jcl",
+ src: "//* COMMENT 'Q\n//S1 EXEC PGM=X,PARM='IT''S'\n",
+ keep: []string{"//S1 EXEC PGM=X,PARM="},
+ gone: []string{"//* COMMENT 'Q", "'IT''S'"}},
+}
+
+func TestMaskRules(t *testing.T) {
+ for _, tc := range maskCases {
+ t.Run(tc.name, func(t *testing.T) { checkCase(t, Mask, tc) })
+ }
+}
+
+func TestMaskComments(t *testing.T) {
+ cases := []maskCase{
+ {name: "c include kept", lang: "c",
+ src: "#include \"x.h\" // c\n/* b */ int a;\n",
+ keep: []string{"#include \"x.h\" ", "int a;"},
+ gone: []string{"// c", "/* b */"}},
+ {name: "comment marker inside string", lang: "python",
+ src: "s = 'a # b' # c\n",
+ keep: []string{"s = 'a # b'"},
+ gone: []string{"# c"}},
+ {name: "js url string", lang: "javascript",
+ src: "const u = \"http://x\"; // c\n",
+ keep: []string{"const u = \"http://x\";"},
+ gone: []string{"// c"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) { checkCase(t, MaskComments, tc) })
+ }
+}
+
+func TestMaskFilePHP(t *testing.T) {
+ src := "don't\n\nt= $v ?>\n"
+ checkCase(t, MaskFile, maskCase{name: "php file", lang: "php", src: src,
+ keep: []string{"", "= $v ?>"},
+ gone: []string{"don't", "'x'", "t"}})
+ // Mask starts in code mode: the same text before ` %q", fn.name, name, k, src[k], got[k])
+ break
+ }
+ }
+ if fn.name != "MaskInactivePreprocessor" {
+ if again := fn.f(lang, got); again != got {
+ t.Errorf("%s %s: not idempotent", fn.name, name)
+ }
+ }
+ }
+ }
+}
+
+// TestMaskTruncated: every prefix of every testdata file masks without
+// panicking (symbol snippets and unterminated constructs).
+func TestMaskTruncated(t *testing.T) {
+ files, _ := filepath.Glob("testdata/*")
+ for _, f := range files {
+ b, _ := os.ReadFile(f)
+ src, lang := string(b), langOfPath(f)
+ for n := 0; n <= len(src); n++ {
+ if got := MaskFile(lang, src[:n]); len(got) != n {
+ t.Fatalf("%s[:%d]: length %d", f, n, len(got))
+ }
+ if got := Mask(lang, src[n:]); len(got) != len(src)-n {
+ t.Fatalf("%s[%d:]: length %d", f, n, len(got))
+ }
+ }
+ }
+}
+
+func BenchmarkMask(b *testing.B) {
+ files, _ := filepath.Glob("testdata/*")
+ byLang := map[string]string{}
+ for _, f := range files {
+ data, _ := os.ReadFile(f)
+ byLang[langOfPath(f)] += string(data) + "\n"
+ }
+ for _, lang := range []string{"go", "python", "javascript", "cpp", "csharp", "rust", "php"} {
+ unit := byLang[lang]
+ var sb strings.Builder
+ for sb.Len() < 1<<20 {
+ sb.WriteString(unit)
+ }
+ src := sb.String()
+ b.Run(lang, func(b *testing.B) {
+ b.SetBytes(int64(len(src)))
+ for i := 0; i < b.N; i++ {
+ Mask(lang, src)
+ }
+ })
+ }
+}
diff --git a/treeedit.go b/treeedit.go
new file mode 100644
index 0000000..50e5186
--- /dev/null
+++ b/treeedit.go
@@ -0,0 +1,60 @@
+package astkit
+
+/*
+#include
+
+// Layouts of tree-sitter's api.h TSPoint and TSInputEdit; ts_tree_edit is
+// linked from go-tree-sitter's runtime.
+typedef struct { uint32_t row; uint32_t column; } astkitPoint;
+typedef struct {
+ uint32_t start_byte, old_end_byte, new_end_byte;
+ astkitPoint start_point, old_end_point, new_end_point;
+} astkitInputEdit;
+
+void ts_tree_edit(void *self, const astkitInputEdit *edit);
+
+static void astkit_tree_edit(void *tree, astkitInputEdit edit) { ts_tree_edit(tree, &edit); }
+*/
+import "C"
+
+import (
+ "reflect"
+ "unsafe"
+
+ sitter "github.com/smacker/go-tree-sitter"
+)
+
+// treeEdit applies e to tree like sitter.Tree.Edit, which passes
+// OldEndPoint as the new end point (go-tree-sitter bug), so an edit that
+// adds lines never moves a row. It reaches the native tree through
+// go-tree-sitter's unexported BaseTree.c and reports false when that
+// layout is not there.
+func treeEdit(tree *sitter.Tree, e sitter.EditInput) bool {
+ ptr := nativeTree(tree)
+ if ptr == nil {
+ return false
+ }
+ pt := func(p sitter.Point) C.astkitPoint {
+ return C.astkitPoint{row: C.uint32_t(p.Row), column: C.uint32_t(p.Column)}
+ }
+ C.astkit_tree_edit(ptr, C.astkitInputEdit{
+ start_byte: C.uint32_t(e.StartIndex),
+ old_end_byte: C.uint32_t(e.OldEndIndex),
+ new_end_byte: C.uint32_t(e.NewEndIndex),
+ start_point: pt(e.StartPoint),
+ old_end_point: pt(e.OldEndPoint),
+ new_end_point: pt(e.NewEndPoint),
+ })
+ return true
+}
+
+func nativeTree(tree *sitter.Tree) unsafe.Pointer {
+ if tree == nil || tree.BaseTree == nil {
+ return nil
+ }
+ f := reflect.ValueOf(tree.BaseTree).Elem().FieldByName("c")
+ if !f.IsValid() || f.Kind() != reflect.Pointer || f.IsNil() {
+ return nil
+ }
+ return f.UnsafePointer()
+}
diff --git a/treeedit_test.go b/treeedit_test.go
new file mode 100644
index 0000000..cb4be95
--- /dev/null
+++ b/treeedit_test.go
@@ -0,0 +1,20 @@
+package astkit
+
+import (
+ "context"
+ "testing"
+)
+
+// treeEdit reaches the native tree through go-tree-sitter's unexported
+// layout; without it Kotlin falls back to a parse whose recovery comments
+// can move.
+func TestTreeEditReachesNativeTree(t *testing.T) {
+ tree, err := NewEngine().Parse(context.Background(), LangGo, []byte("package p\n"))
+ if err != nil || tree == nil {
+ t.Fatalf("parse: %v", err)
+ }
+ defer tree.Close()
+ if nativeTree(tree) == nil {
+ t.Fatal("go-tree-sitter's Tree layout changed: nativeTree found no native tree")
+ }
+}