on main help sign in
feat: slices as well
commit61ea243e489bb519de123feab56986f15347ce4c authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 25 Jul 2025, 12:07 (1 year ago) parent615c8d2
8 files changed+363−55
internal/app/app.go+5−5 internal/app/lsp.go+1−3 internal/csync/maps.go+1−5 internal/csync/slices.go+127 internal/csync/slices_test.go+209 internal/llm/agent/mcp-tools.go+7−12 internal/llm/prompt/prompt.go+6−18 internal/lsp/watcher/watcher.go+7−12
internal/app/app.go+5−5
@@ -12,6 +12,7 @@ import (
1212
1313 tea "github.com/charmbracelet/bubbletea/v2"
1414 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
1516 "github.com/charmbracelet/crush/internal/db"
1617 "github.com/charmbracelet/crush/internal/format"
1718 "github.com/charmbracelet/crush/internal/history"
@@ -37,8 +38,7 @@ type App struct {
3738
3839 clientsMutex sync.RWMutex
3940
40− watcherCancelFuncs []context.CancelFunc
41− cancelFuncsMutex sync.Mutex
41+ watcherCancelFuncs *csync.Slice[context.CancelFunc]
4242 lspWatcherWG sync.WaitGroup
4343
4444 config *config.Config
@@ -76,6 +76,8 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
7676
7777 config: cfg,
7878
79+ watcherCancelFuncs: csync.NewSlice[context.CancelFunc](),
80+
7981 events: make(chan tea.Msg, 100),
8082 serviceEventsWG: &sync.WaitGroup{},
8183 tuiWG: &sync.WaitGroup{},
@@ -305,11 +307,9 @@ func (app *App) Shutdown() {
305307 app.CoderAgent.CancelAll()
306308 }
307309
308− app.cancelFuncsMutex.Lock()
309− for _, cancel := range app.watcherCancelFuncs {
310+ for cancel := range app.watcherCancelFuncs.Seq() {
310311 cancel()
311312 }
312− app.cancelFuncsMutex.Unlock()
313313
314314 // Wait for all LSP watchers to finish.
315315 app.lspWatcherWG.Wait()
internal/app/lsp.go+1−3
@@ -63,9 +63,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
6363 workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient)
6464
6565 // Store the cancel function to be called during cleanup.
66− app.cancelFuncsMutex.Lock()
67− app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc)
68− app.cancelFuncsMutex.Unlock()
66+ app.watcherCancelFuncs.Append(cancelFunc)
6967
7068 // Add to map with mutex protection before starting goroutine
7169 app.clientsMutex.Lock()
internal/csync/maps.go+1−5
@@ -82,12 +82,8 @@ func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
8282
8383// Seq returns an iter.Seq that yields values from the map.
8484func (m *Map[K, V]) Seq() iter.Seq[V] {
85− dst := make(map[K]V)
86− m.mu.RLock()
87− maps.Copy(dst, m.inner)
88− m.mu.RUnlock()
8985 return func(yield func(V) bool) {
90− for _, v := range dst {
86+ for _, v := range m.Seq2() {
9187 if !yield(v) {
9288 return
9389 }
internal/csync/slices.go+127
@@ -2,6 +2,7 @@ package csync
22
33import (
44 "iter"
5+ "slices"
56 "sync"
67)
78
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
3435 }
3536 }
3637}
38+
39+// Slice is a thread-safe slice implementation that provides concurrent access.
40+type Slice[T any] struct {
41+ inner []T
42+ mu sync.RWMutex
43+}
44+
45+// NewSlice creates a new thread-safe slice.
46+func NewSlice[T any]() *Slice[T] {
47+ return &Slice[T]{
48+ inner: make([]T, 0),
49+ }
50+}
51+
52+// NewSliceFrom creates a new thread-safe slice from an existing slice.
53+func NewSliceFrom[T any](s []T) *Slice[T] {
54+ inner := make([]T, len(s))
55+ copy(inner, s)
56+ return &Slice[T]{
57+ inner: inner,
58+ }
59+}
60+
61+// Append adds an element to the end of the slice.
62+func (s *Slice[T]) Append(items ...T) {
63+ s.mu.Lock()
64+ defer s.mu.Unlock()
65+ s.inner = append(s.inner, items...)
66+}
67+
68+// Prepend adds an element to the beginning of the slice.
69+func (s *Slice[T]) Prepend(item T) {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+ s.inner = append([]T{item}, s.inner...)
73+}
74+
75+// Delete removes the element at the specified index.
76+func (s *Slice[T]) Delete(index int) bool {
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ if index < 0 || index >= len(s.inner) {
80+ return false
81+ }
82+ s.inner = slices.Delete(s.inner, index, index+1)
83+ return true
84+}
85+
86+// Get returns the element at the specified index.
87+func (s *Slice[T]) Get(index int) (T, bool) {
88+ s.mu.RLock()
89+ defer s.mu.RUnlock()
90+ var zero T
91+ if index < 0 || index >= len(s.inner) {
92+ return zero, false
93+ }
94+ return s.inner[index], true
95+}
96+
97+// Set updates the element at the specified index.
98+func (s *Slice[T]) Set(index int, item T) bool {
99+ s.mu.Lock()
100+ defer s.mu.Unlock()
101+ if index < 0 || index >= len(s.inner) {
102+ return false
103+ }
104+ s.inner[index] = item
105+ return true
106+}
107+
108+// Len returns the number of elements in the slice.
109+func (s *Slice[T]) Len() int {
110+ s.mu.RLock()
111+ defer s.mu.RUnlock()
112+ return len(s.inner)
113+}
114+
115+// Slice returns a copy of the underlying slice.
116+func (s *Slice[T]) Slice() []T {
117+ s.mu.RLock()
118+ defer s.mu.RUnlock()
119+ result := make([]T, len(s.inner))
120+ copy(result, s.inner)
121+ return result
122+}
123+
124+// SetSlice replaces the entire slice with a new one.
125+func (s *Slice[T]) SetSlice(items []T) {
126+ s.mu.Lock()
127+ defer s.mu.Unlock()
128+ s.inner = make([]T, len(items))
129+ copy(s.inner, items)
130+}
131+
132+// Clear removes all elements from the slice.
133+func (s *Slice[T]) Clear() {
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ s.inner = s.inner[:0]
137+}
138+
139+// Seq returns an iterator that yields elements from the slice.
140+func (s *Slice[T]) Seq() iter.Seq[T] {
141+ return func(yield func(T) bool) {
142+ for _, v := range s.Seq2() {
143+ if !yield(v) {
144+ return
145+ }
146+ }
147+ }
148+}
149+
150+// Seq2 returns an iterator that yields index-value pairs from the slice.
151+func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
152+ s.mu.RLock()
153+ items := make([]T, len(s.inner))
154+ copy(items, s.inner)
155+ s.mu.RUnlock()
156+ return func(yield func(int, T) bool) {
157+ for i, v := range items {
158+ if !yield(i, v) {
159+ return
160+ }
161+ }
162+ }
163+}
internal/csync/slices_test.go+209
@@ -1,11 +1,13 @@
11package csync
22
33import (
4+ "sync"
45 "sync/atomic"
56 "testing"
67 "time"
78
89 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
911)
1012
1113func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
8587
8688 assert.Equal(t, []string{"a", "b"}, result)
8789}
90+
91+func TestSlice(t *testing.T) {
92+ t.Run("NewSlice", func(t *testing.T) {
93+ s := NewSlice[int]()
94+ assert.Equal(t, 0, s.Len())
95+ })
96+
97+ t.Run("NewSliceFrom", func(t *testing.T) {
98+ original := []int{1, 2, 3}
99+ s := NewSliceFrom(original)
100+ assert.Equal(t, 3, s.Len())
101+
102+ // Verify it's a copy, not a reference
103+ original[0] = 999
104+ val, ok := s.Get(0)
105+ require.True(t, ok)
106+ assert.Equal(t, 1, val)
107+ })
108+
109+ t.Run("Append", func(t *testing.T) {
110+ s := NewSlice[string]()
111+ s.Append("hello")
112+ s.Append("world")
113+
114+ assert.Equal(t, 2, s.Len())
115+ val, ok := s.Get(0)
116+ require.True(t, ok)
117+ assert.Equal(t, "hello", val)
118+
119+ val, ok = s.Get(1)
120+ require.True(t, ok)
121+ assert.Equal(t, "world", val)
122+ })
123+
124+ t.Run("Prepend", func(t *testing.T) {
125+ s := NewSlice[string]()
126+ s.Append("world")
127+ s.Prepend("hello")
128+
129+ assert.Equal(t, 2, s.Len())
130+ val, ok := s.Get(0)
131+ require.True(t, ok)
132+ assert.Equal(t, "hello", val)
133+
134+ val, ok = s.Get(1)
135+ require.True(t, ok)
136+ assert.Equal(t, "world", val)
137+ })
138+
139+ t.Run("Delete", func(t *testing.T) {
140+ s := NewSliceFrom([]int{1, 2, 3, 4, 5})
141+
142+ // Delete middle element
143+ ok := s.Delete(2)
144+ assert.True(t, ok)
145+ assert.Equal(t, 4, s.Len())
146+
147+ expected := []int{1, 2, 4, 5}
148+ actual := s.Slice()
149+ assert.Equal(t, expected, actual)
150+
151+ // Delete out of bounds
152+ ok = s.Delete(10)
153+ assert.False(t, ok)
154+ assert.Equal(t, 4, s.Len())
155+
156+ // Delete negative index
157+ ok = s.Delete(-1)
158+ assert.False(t, ok)
159+ assert.Equal(t, 4, s.Len())
160+ })
161+
162+ t.Run("Get", func(t *testing.T) {
163+ s := NewSliceFrom([]string{"a", "b", "c"})
164+
165+ val, ok := s.Get(1)
166+ require.True(t, ok)
167+ assert.Equal(t, "b", val)
168+
169+ // Out of bounds
170+ _, ok = s.Get(10)
171+ assert.False(t, ok)
172+
173+ // Negative index
174+ _, ok = s.Get(-1)
175+ assert.False(t, ok)
176+ })
177+
178+ t.Run("Set", func(t *testing.T) {
179+ s := NewSliceFrom([]string{"a", "b", "c"})
180+
181+ ok := s.Set(1, "modified")
182+ assert.True(t, ok)
183+
184+ val, ok := s.Get(1)
185+ require.True(t, ok)
186+ assert.Equal(t, "modified", val)
187+
188+ // Out of bounds
189+ ok = s.Set(10, "invalid")
190+ assert.False(t, ok)
191+
192+ // Negative index
193+ ok = s.Set(-1, "invalid")
194+ assert.False(t, ok)
195+ })
196+
197+ t.Run("SetSlice", func(t *testing.T) {
198+ s := NewSlice[int]()
199+ s.Append(1)
200+ s.Append(2)
201+
202+ newItems := []int{10, 20, 30}
203+ s.SetSlice(newItems)
204+
205+ assert.Equal(t, 3, s.Len())
206+ assert.Equal(t, newItems, s.Slice())
207+
208+ // Verify it's a copy
209+ newItems[0] = 999
210+ val, ok := s.Get(0)
211+ require.True(t, ok)
212+ assert.Equal(t, 10, val)
213+ })
214+
215+ t.Run("Clear", func(t *testing.T) {
216+ s := NewSliceFrom([]int{1, 2, 3})
217+ assert.Equal(t, 3, s.Len())
218+
219+ s.Clear()
220+ assert.Equal(t, 0, s.Len())
221+ })
222+
223+ t.Run("Slice", func(t *testing.T) {
224+ original := []int{1, 2, 3}
225+ s := NewSliceFrom(original)
226+
227+ copy := s.Slice()
228+ assert.Equal(t, original, copy)
229+
230+ // Verify it's a copy
231+ copy[0] = 999
232+ val, ok := s.Get(0)
233+ require.True(t, ok)
234+ assert.Equal(t, 1, val)
235+ })
236+
237+ t.Run("Seq", func(t *testing.T) {
238+ s := NewSliceFrom([]int{1, 2, 3})
239+
240+ var result []int
241+ for v := range s.Seq() {
242+ result = append(result, v)
243+ }
244+
245+ assert.Equal(t, []int{1, 2, 3}, result)
246+ })
247+
248+ t.Run("SeqWithIndex", func(t *testing.T) {
249+ s := NewSliceFrom([]string{"a", "b", "c"})
250+
251+ var indices []int
252+ var values []string
253+ for i, v := range s.Seq2() {
254+ indices = append(indices, i)
255+ values = append(values, v)
256+ }
257+
258+ assert.Equal(t, []int{0, 1, 2}, indices)
259+ assert.Equal(t, []string{"a", "b", "c"}, values)
260+ })
261+
262+ t.Run("ConcurrentAccess", func(t *testing.T) {
263+ s := NewSlice[int]()
264+ const numGoroutines = 100
265+ const itemsPerGoroutine = 10
266+
267+ var wg sync.WaitGroup
268+
269+ // Concurrent appends
270+ for i := 0; i < numGoroutines; i++ {
271+ wg.Add(1)
272+ go func(start int) {
273+ defer wg.Done()
274+ for j := 0; j < itemsPerGoroutine; j++ {
275+ s.Append(start*itemsPerGoroutine + j)
276+ }
277+ }(i)
278+ }
279+
280+ // Concurrent reads
281+ for i := 0; i < numGoroutines; i++ {
282+ wg.Add(1)
283+ go func() {
284+ defer wg.Done()
285+ for j := 0; j < itemsPerGoroutine; j++ {
286+ s.Len() // Just read the length
287+ }
288+ }()
289+ }
290+
291+ wg.Wait()
292+
293+ // Should have all items
294+ assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
295+ })
296+}
internal/llm/agent/mcp-tools.go+7−12
@@ -5,9 +5,11 @@ import (
55 "encoding/json"
66 "fmt"
77 "log/slog"
8+ "slices"
89 "sync"
910
1011 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/csync"
1113 "github.com/charmbracelet/crush/internal/llm/tools"
1214
1315 "github.com/charmbracelet/crush/internal/permission"
@@ -195,9 +197,8 @@ func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *confi
195197}
196198
197199func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
198− var mu sync.Mutex
199200 var wg sync.WaitGroup
200− var result []tools.BaseTool
201+ result := csync.NewSlice[tools.BaseTool]()
201202 for name, m := range cfg.MCP {
202203 if m.Disabled {
203204 slog.Debug("skipping disabled mcp", "name", name)
@@ -218,9 +219,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
218219 return
219220 }
220221
221− mu.Lock()
222− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
223− mu.Unlock()
222+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
224223 case config.MCPHttp:
225224 c, err := client.NewStreamableHttpClient(
226225 m.URL,
@@ -230,9 +229,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
230229 slog.Error("error creating mcp client", "error", err)
231230 return
232231 }
233− mu.Lock()
234− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
235− mu.Unlock()
232+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
236233 case config.MCPSse:
237234 c, err := client.NewSSEMCPClient(
238235 m.URL,
@@ -242,12 +239,10 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
242239 slog.Error("error creating mcp client", "error", err)
243240 return
244241 }
245− mu.Lock()
246− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
247− mu.Unlock()
242+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
248243 }
249244 }(name, m)
250245 }
251246 wg.Wait()
252− return result
247+ return slices.Collect(result.Seq())
253248}
internal/llm/prompt/prompt.go+6−18
@@ -7,6 +7,7 @@ import (
77 "sync"
88
99 "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/csync"
1011 "github.com/charmbracelet/crush/internal/env"
1112)
1213
@@ -74,8 +75,7 @@ func processContextPaths(workDir string, paths []string) string {
7475 )
7576
7677 // Track processed files to avoid duplicates
77− processedFiles := make(map[string]bool)
78− var processedMutex sync.Mutex
78+ processedFiles := csync.NewMap[string, bool]()
7979
8080 for _, path := range paths {
8181 wg.Add(1)
@@ -106,14 +106,8 @@ func processContextPaths(workDir string, paths []string) string {
106106 // Check if we've already processed this file (case-insensitive)
107107 lowerPath := strings.ToLower(path)
108108
109− processedMutex.Lock()
110− alreadyProcessed := processedFiles[lowerPath]
111− if !alreadyProcessed {
112− processedFiles[lowerPath] = true
113− }
114− processedMutex.Unlock()
115−
116− if !alreadyProcessed {
109+ if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
110+ processedFiles.Set(lowerPath, true)
117111 if result := processFile(path); result != "" {
118112 resultCh <- result
119113 }
@@ -126,14 +120,8 @@ func processContextPaths(workDir string, paths []string) string {
126120 // Check if we've already processed this file (case-insensitive)
127121 lowerPath := strings.ToLower(fullPath)
128122
129− processedMutex.Lock()
130− alreadyProcessed := processedFiles[lowerPath]
131− if !alreadyProcessed {
132− processedFiles[lowerPath] = true
133− }
134− processedMutex.Unlock()
135−
136− if !alreadyProcessed {
123+ if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
124+ processedFiles.Set(lowerPath, true)
137125 result := processFile(fullPath)
138126 if result != "" {
139127 resultCh <- result
internal/lsp/watcher/watcher.go+7−12
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/bmatcuk/doublestar/v4"
1414 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
1516
1617 "github.com/charmbracelet/crush/internal/lsp"
1718 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -25,8 +26,7 @@ type WorkspaceWatcher struct {
2526 workspacePath string
2627
2728 debounceTime time.Duration
28− debounceMap map[string]*time.Timer
29− debounceMu sync.Mutex
29+ debounceMap *csync.Map[string, *time.Timer]
3030
3131 // File watchers registered by the server
3232 registrations []protocol.FileSystemWatcher
@@ -46,7 +46,7 @@ func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
4646 name: name,
4747 client: client,
4848 debounceTime: 300 * time.Millisecond,
49− debounceMap: make(map[string]*time.Timer),
49+ debounceMap: csync.NewMap[string, *time.Timer](),
5050 registrations: []protocol.FileSystemWatcher{},
5151 }
5252}
@@ -635,26 +635,21 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
635635
636636// debounceHandleFileEvent handles file events with debouncing to reduce notifications
637637func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
638− w.debounceMu.Lock()
639− defer w.debounceMu.Unlock()
640−
641638 // Create a unique key based on URI and change type
642639 key := fmt.Sprintf("%s:%d", uri, changeType)
643640
644641 // Cancel existing timer if any
645− if timer, exists := w.debounceMap[key]; exists {
642+ if timer, exists := w.debounceMap.Get(key); exists {
646643 timer.Stop()
647644 }
648645
649646 // Create new timer
650− w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
647+ w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
651648 w.handleFileEvent(ctx, uri, changeType)
652649
653650 // Cleanup timer after execution
654− w.debounceMu.Lock()
655− delete(w.debounceMap, key)
656− w.debounceMu.Unlock()
657− })
651+ w.debounceMap.Del(key)
652+ }))
658653}
659654
660655// handleFileEvent sends file change notifications