diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 4f79591..44c01fd 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -39,17 +39,19 @@ const ( // therefore no stale-disk re-read (the old fixed-slot model could re-read a // shifted tail chunk from disk and clobber in-memory edits). // -// Chunks may grow/shrink with edits and are not rebalanced; that is fine for -// correctness (prefix sums) and keeps edits local to the affected chunk(s) -// rather than copying the whole file. +// Chunks grow/shrink with edits; Insert splits any chunk that grows past +// twice the target size (see Insert), so the per-edit copy cost stays bounded +// by O(chunkSize) even under sustained typing at one spot. Shrunken (even +// empty) chunks are left in place: the chunk count never grows with deletes, +// all readers walk actual lengths, and removing chunks would be pure churn. type ChunkedBuffer struct { - filename string - chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary - fileLen int64 // total content length - chunks [][]byte // ordered; chunks[i] is the i-th chunk - dirty bool // true if buffer has been modified - FS pool.FileSystem // filesystem for reads - basePath string // base path for file resolution + filename string + chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary + fileLen int64 // total content length + chunks [][]byte // ordered; chunks[i] is the i-th chunk + dirty bool // true if buffer has been modified + FS pool.FileSystem // filesystem for reads + basePath string // base path for file resolution // line index is built asynchronously LineIndex *types.LineIndex @@ -320,7 +322,15 @@ func (cb *ChunkedBuffer) markDirtyChunk(idx int) { } } -// Insert inserts text at byte position pos, splicing only the affected chunk. +// Insert inserts text at byte position pos, splicing only the affected +// chunk. If the result grows past twice the target chunk size (sustained +// typing at one spot, or a large paste), the chunk is split in half so that +// chunks stay O(chunkSize) and every future edit remains a bounded +// O(chunkSize) copy. The split cut is an arbitrary byte offset, like the +// chunk boundaries created by SetContent: chunk boundaries may fall inside +// multi-byte sequences, which is fine because every reader reassembles whole +// windows from chunk bytes (windows are always line-aligned, i.e. on rune +// boundaries). func (cb *ChunkedBuffer) Insert(pos int, text string) { if len(text) == 0 { return @@ -334,8 +344,16 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) { } idx, local := cb.chunkForPos(pos) if idx < 0 { - // Empty buffer: create the first chunk. - cb.chunks = append(cb.chunks, []byte(text)) + // Empty buffer: chunk the new text directly so a large first paste + // does not create one oversized chunk. + cb.chunks = nil + for start := 0; start < len(text); start += cb.chunkSize { + end := start + cb.chunkSize + if end > len(text) { + end = len(text) + } + cb.chunks = append(cb.chunks, []byte(text[start:end])) + } cb.fileLen = int64(len(text)) cb.markDirtyChunk(0) return @@ -345,6 +363,16 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) { newChunk = append(newChunk, chunk[:local]...) newChunk = append(newChunk, text...) newChunk = append(newChunk, chunk[local:]...) + if len(newChunk) > 2*cb.chunkSize { + // Split in half and insert the second half after idx. + cut := len(newChunk) / 2 + second := make([]byte, len(newChunk)-cut) + copy(second, newChunk[cut:]) + newChunk = newChunk[:cut] + cb.chunks = append(cb.chunks, nil) + copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove) + cb.chunks[idx+1] = second + } cb.chunks[idx] = newChunk cb.fileLen += int64(len(text)) cb.markDirtyChunk(idx) diff --git a/internal/editor/chunked_buffer_test.go b/internal/editor/chunked_buffer_test.go index c1fe231..28caf92 100644 --- a/internal/editor/chunked_buffer_test.go +++ b/internal/editor/chunked_buffer_test.go @@ -191,3 +191,91 @@ func TestAppendAtEnd(t *testing.T) { t.Fatalf("FileLen=%d, want 9", cb.FileLen()) } } + +// assertMaxChunkSize verifies the chunking invariant: no chunk may exceed +// twice the target size (Insert splits oversized chunks in half). +func assertMaxChunkSize(t *testing.T, cb *ChunkedBuffer) { + t.Helper() + for i, c := range cb.chunks { + if len(c) > 2*cb.chunkSize { + t.Fatalf("chunk %d is %d bytes, want <= %d (2 x %d)", i, len(c), 2*cb.chunkSize, cb.chunkSize) + } + } +} + +// TestInsert_SustainedTyping_SplitsOversizedChunks simulates sustained typing +// at one spot (many small inserts into the same chunk). Without the split, the +// target chunk grows without bound and every edit copies the whole grown +// chunk (quadratic total). With the split, chunks stay O(chunkSize). +func TestInsert_SustainedTyping_SplitsOversizedChunks(t *testing.T) { + const chunkSize = 64 + cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "") + seed := strings.Repeat("seed line\n", chunkSize/8) // 64 bytes = 1 chunk + cb.SetContent([]byte(seed)) + + // Type 2000 characters, each inserted immediately after the previous one, + // starting at position 10 (inside chunk 0). + var typed strings.Builder + pos := 10 + for i := 0; i < 2000; i++ { + r := rune('a' + i%26) + cb.Insert(pos, string(r)) + pos++ + typed.WriteRune(r) + if i%200 == 0 { + assertMaxChunkSize(t, cb) + } + } + assertMaxChunkSize(t, cb) + want := seed[:10] + typed.String() + seed[10:] + full, _ := cb.FullContent() + if full != want { + t.Fatalf("FullContent mismatch after sustained inserts (len %d vs %d)", len(full), len(want)) + } + if cb.FileLen() != int64(len(want)) { + t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(want)) + } +} + +// TestInsert_LargePaste_EmptyBuffer verifies that a large first paste into an +// empty buffer is chunked (not one oversized chunk) and reconstructs exactly. +func TestInsert_LargePaste_EmptyBuffer(t *testing.T) { + cb := NewChunkedBuffer("/test.txt", 64*1024, nil, "") + paste := strings.Repeat("0123456789", 100*1024) // 1 MB + cb.Insert(0, paste) + assertMaxChunkSize(t, cb) + if got := len(cb.chunks); got != 16 { + t.Fatalf("expected 16 chunks for 1MB/64KB, got %d", got) + } + full, _ := cb.FullContent() + if full != paste { + t.Fatalf("FullContent mismatch after large paste") + } +} + +// TestInsert_SplitContentIntegrity checks that Content stays exact across the +// NEW chunk boundary created by a split, including spans that cross it. +func TestInsert_SplitContentIntegrity(t *testing.T) { + const chunkSize = 64 + cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "") + cb.SetContent([]byte(strings.Repeat("x", 64))) + // 200 inserts of 2 chars at pos 0: the first chunk grows to 464+ bytes and + // must be split several times. + for i := 0; i < 200; i++ { + cb.Insert(0, "AB") + } + want := strings.Repeat("AB", 200) + strings.Repeat("x", 64) + full, _ := cb.FullContent() + if full != want { + t.Fatalf("FullContent mismatch: got %d bytes, want %d", len(full), len(want)) + } + // Span across several chunk boundaries near the front. + seg := cb.Content(0, 200) + if seg != want[:200] { + t.Fatalf("Content(0,200) mismatch across split boundary") + } + seg = cb.Content(50, 300) + if seg != want[50:300] { + t.Fatalf("Content(50,300) mismatch across split boundary") + } +}