on main help sign in
perf(fsext,skills,lsp): prune walks, cache counts, linear edits

- fsext.SkipHidden: hoist the ignored-dirs map to package scope instead of rebuilding it on every call. - skills discovery: prune ignored directories (node_modules, .git, etc.) during the fastwalk instead of stat-ing every file in every subtree. - lsp: maintain the diagnostics total incrementally instead of recounting all files on every publishDiagnostics notification. - lsp/util: apply text edits with a single-pass overlap check (sufficient once edits are start-sorted) and in-place slices.Replace, replacing the O(n²) overlap loop and per-edit full-slice rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit5dd90d8bbd72cab3f2af215030bc5931308479e4 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:18 (3 months ago) parent0352d74
5 files changed+105−62
internal/fsext/fileutil.go+23−21 internal/lsp/client.go+5 internal/lsp/handlers.go+6−6 internal/lsp/util/edit.go+41−34 internal/skills/skills.go+30−1
internal/fsext/fileutil.go+23−21
@@ -22,6 +22,29 @@ type FileInfo struct {
2222 ModTime time.Time
2323}
2424
25+// commonIgnoredDirs is a set of directory names that SkipHidden always ignores.
26+// Hoisted to package scope so it is built once instead of on every call.
27+var commonIgnoredDirs = map[string]bool{
28+ ".crush": true,
29+ "node_modules": true,
30+ "vendor": true,
31+ "dist": true,
32+ "build": true,
33+ "target": true,
34+ ".git": true,
35+ ".idea": true,
36+ ".vscode": true,
37+ "__pycache__": true,
38+ "bin": true,
39+ "obj": true,
40+ "out": true,
41+ "coverage": true,
42+ "logs": true,
43+ "generated": true,
44+ "bower_components": true,
45+ "jspm_packages": true,
46+}
47+
2548func SkipHidden(path string) bool {
2649 // Check for hidden files (starting with a dot)
2750 base := filepath.Base(path)
@@ -29,27 +52,6 @@ func SkipHidden(path string) bool {
2952 return true
3053 }
3154
32− commonIgnoredDirs := map[string]bool{
33− ".crush": true,
34− "node_modules": true,
35− "vendor": true,
36− "dist": true,
37− "build": true,
38− "target": true,
39− ".git": true,
40− ".idea": true,
41− ".vscode": true,
42− "__pycache__": true,
43− "bin": true,
44− "obj": true,
45− "out": true,
46− "coverage": true,
47− "logs": true,
48− "generated": true,
49− "bower_components": true,
50− "jspm_packages": true,
51− }
52−
5355 parts := strings.SplitSeq(path, string(os.PathSeparator))
5456 for part := range parts {
5557 if commonIgnoredDirs[part] {
internal/lsp/client.go+5
@@ -53,6 +53,11 @@ type Client struct {
5353 // Diagnostic cache
5454 diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
5555
56+ // diagnosticsTotal is a running total of diagnostics across all files,
57+ // maintained incrementally as diagnostics are set to avoid recounting
58+ // every file on each publishDiagnostics notification.
59+ diagnosticsTotal atomic.Int64
60+
5661 // Cached diagnostic counts to avoid map copy on every UI render.
5762 diagCountsCache DiagnosticCounts
5863 diagCountsVersion uint64
internal/lsp/handlers.go+6−6
@@ -108,13 +108,13 @@ func HandleDiagnostics(client *Client, params json.RawMessage) {
108108 return
109109 }
110110
111+ // Maintain the running total incrementally: subtract the previous
112+ // diagnostics for this URI (if any) and add the new ones. This avoids
113+ // recounting every file's diagnostics on each notification.
114+ previous, _ := client.diagnostics.Get(diagParams.URI)
111115 client.diagnostics.Set(diagParams.URI, diagParams.Diagnostics)
112−
113− // Calculate total diagnostic count
114− totalCount := 0
115− for _, diagnostics := range client.diagnostics.Seq2() {
116− totalCount += len(diagnostics)
117− }
116+ delta := len(diagParams.Diagnostics) - len(previous)
117+ totalCount := int(client.diagnosticsTotal.Add(int64(delta)))
118118
119119 // Trigger callback if set
120120 if client.onDiagnosticsChanged != nil {
internal/lsp/util/edit.go+41−34
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "fmt"
66 "os"
7+ "slices"
78 "sort"
89 "strings"
910
@@ -37,28 +38,40 @@ func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit, encodin
3738 // Split into lines without the endings
3839 lines := strings.Split(string(content), lineEnding)
3940
40− // Check for overlapping edits
41− for i, edit1 := range edits {
42− for j := i + 1; j < len(edits); j++ {
43− if rangesOverlap(edit1.Range, edits[j].Range) {
44− return fmt.Errorf("overlapping edits detected between edit %d and %d", i, j)
45− }
46− }
47− }
48−
49− // Sort edits in reverse order
41+ // Sort edits once by ascending start position (end position as a stable
42+ // tiebreaker). This lets us both detect overlaps in a single linear pass
43+ // and apply edits from the bottom of the file upward.
5044 sortedEdits := make([]protocol.TextEdit, len(edits))
5145 copy(sortedEdits, edits)
5246 sort.Slice(sortedEdits, func(i, j int) bool {
53− if sortedEdits[i].Range.Start.Line != sortedEdits[j].Range.Start.Line {
54− return sortedEdits[i].Range.Start.Line > sortedEdits[j].Range.Start.Line
47+ a, b := sortedEdits[i].Range, sortedEdits[j].Range
48+ if a.Start.Line != b.Start.Line {
49+ return a.Start.Line < b.Start.Line
5550 }
56− return sortedEdits[i].Range.Start.Character > sortedEdits[j].Range.Start.Character
51+ if a.Start.Character != b.Start.Character {
52+ return a.Start.Character < b.Start.Character
53+ }
54+ if a.End.Line != b.End.Line {
55+ return a.End.Line < b.End.Line
56+ }
57+ return a.End.Character < b.End.Character
5758 })
5859
59− // Apply each edit
60− for _, edit := range sortedEdits {
61− newLines, err := applyTextEdit(lines, edit, encoding)
60+ // Detect overlapping edits in a single linear pass. Because the edits are
61+ // sorted by ascending start, any overlap manifests between two adjacent
62+ // edits, so checking neighbors is sufficient.
63+ for i := 1; i < len(sortedEdits); i++ {
64+ if rangesOverlap(sortedEdits[i-1].Range, sortedEdits[i].Range) {
65+ return fmt.Errorf("overlapping edits detected between edit %d and %d", i-1, i)
66+ }
67+ }
68+
69+ // Apply edits from the bottom of the file upward so that earlier edits'
70+ // line/character offsets are not shifted by later ones. applyTextEdit
71+ // splices the affected range in place instead of rebuilding the whole
72+ // slice per edit.
73+ for i := len(sortedEdits) - 1; i >= 0; i-- {
74+ newLines, err := applyTextEdit(lines, sortedEdits[i], encoding)
6275 if err != nil {
6376 return fmt.Errorf("failed to apply edit: %w", err)
6477 }
@@ -118,12 +131,6 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
118131 endChar = utf32ToByteOffset(endLineContent, edit.Range.End.Character)
119132 }
120133
121− // Create result slice with initial capacity
122− result := make([]string, 0, len(lines))
123−
124− // Copy lines before edit
125− result = append(result, lines[:startLine]...)
126−
127134 // Get the prefix of the start line
128135 startLineContent := lines[startLine]
129136 if startChar < 0 || startChar > len(startLineContent) {
@@ -138,10 +145,11 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
138145 }
139146 suffix := endLineContent[endChar:]
140147
141− // Handle the edit
148+ // Build the replacement lines for the edited range [startLine, endLine].
149+ var region []string
142150 if edit.NewText == "" {
143151 if prefix+suffix != "" {
144− result = append(result, prefix+suffix)
152+ region = []string{prefix + suffix}
145153 }
146154 } else {
147155 // Split new text into lines, being careful not to add extra newlines
@@ -150,21 +158,20 @@ func applyTextEdit(lines []string, edit protocol.TextEdit, encoding powernap.Off
150158
151159 if len(newLines) == 1 {
152160 // Single line change
153− result = append(result, prefix+newLines[0]+suffix)
161+ region = []string{prefix + newLines[0] + suffix}
154162 } else {
155163 // Multi-line change
156− result = append(result, prefix+newLines[0])
157− result = append(result, newLines[1:len(newLines)-1]...)
158− result = append(result, newLines[len(newLines)-1]+suffix)
164+ region = make([]string, 0, len(newLines))
165+ region = append(region, prefix+newLines[0])
166+ region = append(region, newLines[1:len(newLines)-1]...)
167+ region = append(region, newLines[len(newLines)-1]+suffix)
159168 }
160169 }
161170
162− // Add remaining lines
163− if endLine+1 < len(lines) {
164− result = append(result, lines[endLine+1:]...)
165− }
166−
167− return result, nil
171+ // Splice the replacement in place instead of rebuilding the whole slice.
172+ // Callers apply edits from the bottom of the file upward, so mutating the
173+ // shared slice does not disturb not-yet-applied (earlier) edits.
174+ return slices.Replace(lines, startLine, endLine+1, region...), nil
168175}
169176
170177// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
internal/skills/skills.go+30−1
@@ -34,6 +34,26 @@ var (
3434 latestStatesMu sync.RWMutex
3535)
3636
37+// ignoredWalkDirs is a set of directory names that skill discovery never
38+// descends into. Pruning these avoids walking large dependency/VCS trees that
39+// cannot contain user skills.
40+var ignoredWalkDirs = map[string]bool{
41+ "node_modules": true,
42+ ".git": true,
43+ ".svn": true,
44+ ".hg": true,
45+ "vendor": true,
46+ "dist": true,
47+ "build": true,
48+ "target": true,
49+ ".idea": true,
50+ ".vscode": true,
51+ "__pycache__": true,
52+ ".cache": true,
53+ "bin": true,
54+ "obj": true,
55+}
56+
3757// Skill represents a parsed SKILL.md file.
3858type Skill struct {
3959 Name string `yaml:"name" json:"name"`
@@ -249,7 +269,16 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
249269 addState("", path, StateError, err)
250270 return nil
251271 }
252− if d.IsDir() || d.Name() != SkillFileName {
272+ if d.IsDir() {
273+ // Prune large/irrelevant directory trees so discovery does
274+ // not descend into dependency and VCS directories. Never
275+ // prune the walk root itself.
276+ if path != base && ignoredWalkDirs[d.Name()] {
277+ return filepath.SkipDir
278+ }
279+ return nil
280+ }
281+ if d.Name() != SkillFileName {
253282 return nil
254283 }
255284 mu.Lock()