package editor // Differential fuzzing for the incrementally-maintained LineIndex: every // random edit is applied to the buffer AND replayed on the LineIndex with the // exact production composition (UpdateLineIndexAfterDelete then // UpdateLineIndexAfterInsert for a replace), and after every op the index must // equal a full recomputation from the current content (lineStartOffsets // oracle). LineIndex drift never corrupts the file (disk writes derive only // from chunks), but it silently corrupts every line-based UI feature // (scroll window, page keys, tap mapping, status bar), so it gets the same // differential treatment as the buffer itself. import ( "bytes" "fmt" "math/rand" "strings" "testing" "pad/internal/io/pool/types" ) // buildFuzzContent generates nLines pseudo-random lines (seeded) with a mix // of ASCII, CJK and multi-byte content; noNewlines/crlf shape the line // structure for the edge cases. func buildFuzzContent(rng *rand.Rand, nLines int, noNewlines, crlf bool) string { var sb strings.Builder for i := 0; i < nLines; i++ { sb.WriteString(fuzzRuneString(rng, rng.Intn(40)+1)) if i < nLines-1 || (crlf && i == nLines-1) { if !noNewlines { if crlf { sb.WriteString("\r\n") } else { sb.WriteByte('\n') } } } } if noNewlines { sb.WriteString(fuzzRuneString(rng, 20)) } return sb.String() } func fuzzLineIndex(t *testing.T, label string, initial string, ops int) { t.Helper() chunkSize := 32 rng := rand.New(rand.NewSource(int64(len(initial))*7919 + 13)) cb := NewChunkedBuffer("/lineidx.txt", chunkSize, nil, "") var model []byte = []byte(initial) cb.SetContent(model) // Build the index the way the app does, then mutate it incrementally. cb.LineIndex = types.NewLineIndex(lineStartOffsets(model), 0, int64(len(model))) modelCap := 32 * 1024 for i := 0; i < ops; i++ { if len(model) > modelCap { // Trim back (rune-aligned) to keep the oracle cheap. p := runeStartPositions(model) a, b := p[len(p)/4], p[len(p)/2] cb.Delete(a, b-a) cb.UpdateLineIndexAfterDelete(a, b) model = modelDelete(model, a, b) } // One random replace op (covers insert: b==a, and delete: text==""). p := runeStartPositions(model) a := p[rng.Intn(len(p))] b := p[rng.Intn(len(p))] if a > b { a, b = b, a } var text string switch rng.Intn(10) { case 0: text = "" // pure delete case 1: text = fuzzRuneString(rng, rng.Intn(30)+1) // no newlines default: // Text that adds structure: newlines / CRLF / CJK. text = fuzzRuneString(rng, rng.Intn(40)+1) switch rng.Intn(3) { case 0: text += "\n" + fuzzRuneString(rng, rng.Intn(10)) case 1: text = "\n" + text case 2: text += "\r\n" } } // Production composition order (state.go HandleReplaceRange): if b > a { cb.Delete(a, b-a) model = modelDelete(model, a, b) cb.UpdateLineIndexAfterDelete(a, b) } if text != "" { cb.Insert(a, text) model = modelInsert(model, a, []byte(text)) cb.UpdateLineIndexAfterInsert(a, text) } assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("%s op %d", label, i)) } } func TestLineIndex_Fuzz_RandomEdits(t *testing.T) { const ops = 3000 cases := []struct { name string content string }{ {"mixedLines", buildFuzzContent(rand.New(rand.NewSource(1)), 80, false, false)}, {"trailingNewline", buildFuzzContent(rand.New(rand.NewSource(2)), 60, false, false) + "\n"}, {"noNewlines", fuzzRuneString(rand.New(rand.NewSource(3)), 800)}, {"singleLine", "only one line, no newlines at all, fairly long"}, {"crlfLines", buildFuzzContent(rand.New(rand.NewSource(4)), 60, false, true)}, {"emptyStart", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fuzzLineIndex(t, c.name, c.content, ops) }) } } // TestLineIndex_Fuzz_TrailingEmptyLine pins the invariant that a file ending // in '\n' has a trailing empty line in the index, across random edits that // add/remove the final newline. func TestLineIndex_Fuzz_TrailingEmptyLine(t *testing.T) { rng := rand.New(rand.NewSource(5)) cb := NewChunkedBuffer("/trail.txt", 16, nil, "") model := []byte("a\nb\nc\n") cb.SetContent(model) cb.LineIndex = types.NewLineIndex(lineStartOffsets(model), 0, int64(len(model))) if cb.LineIndex.LineCount() != 4 { t.Fatalf("initial LineCount=%d, want 4 (trailing empty line)", cb.LineIndex.LineCount()) } for i := 0; i < 1500; i++ { // Randomly add/remove the final newline and edit around the end. op := rng.Intn(4) switch op { case 0: // ensure ends with newline if len(model) > 0 && model[len(model)-1] != '\n' { pos := len(model) cb.Insert(pos, "\n") model = modelInsert(model, pos, []byte("\n")) cb.UpdateLineIndexAfterInsert(pos, "\n") } case 1: // strip trailing newlines oldLen := len(model) end := oldLen for end > 0 && model[end-1] == '\n' { end-- } if end < oldLen { cb.Delete(end, oldLen-end) model = modelDelete(model, end, oldLen) cb.UpdateLineIndexAfterDelete(end, oldLen) } case 2: // append a line at the end pos := len(model) text := fuzzRuneString(rng, rng.Intn(10)+1) + "\n" cb.Insert(pos, text) model = modelInsert(model, pos, []byte(text)) cb.UpdateLineIndexAfterInsert(pos, text) case 3: // delete the last line if last := bytesLastLineStart(model); last >= 0 { oldLen := len(model) cb.Delete(last, oldLen-last) model = modelDelete(model, last, oldLen) cb.UpdateLineIndexAfterDelete(last, oldLen) } } assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d", i)) // Structural invariant: line count = newlines + 1, and a trailing '\n' // shows up as the index's last offset landing exactly at len(model) // (the trailing empty line). endsNL := len(model) > 0 && model[len(model)-1] == '\n' wantLines := 1 + utf8RuneCountNewlines(model) if cb.LineIndex.LineCount() != wantLines { t.Fatalf("op %d: LineCount=%d, want %d", i, cb.LineIndex.LineCount(), wantLines) } offs := cb.LineIndex.Offsets lastIsEnd := offs[len(offs)-1] == int32(len(model)) // The final line starts at EOF iff the file is empty or ends in '\n' // (i.e. the final line is empty). wantLastIsEnd := len(model) == 0 || endsNL if lastIsEnd != wantLastIsEnd { t.Fatalf("op %d: last line start at %d, len %d, endsNL=%v", i, offs[len(offs)-1], len(model), endsNL) } } } // bytesLastLineStart returns the byte offset of the start of the last line in // m (the position after the previous '\n', or 0), or -1 if m is empty. func bytesLastLineStart(m []byte) int { if len(m) == 0 { return -1 } if i := bytes.LastIndexByte(m, '\n'); i >= 0 { return i + 1 } return 0 } // utf8RuneCountNewlines counts '\n' bytes (byte scan; '\n' cannot appear // inside a multi-byte UTF-8 sequence). func utf8RuneCountNewlines(m []byte) int { n := 0 for _, b := range m { if b == '\n' { n++ } } return n }