440 lines
9.7 KiB
Go
440 lines
9.7 KiB
Go
// Package document implements the chunked binary document model.
|
|
//
|
|
// The document holds three conceptual layers per slot:
|
|
//
|
|
// source — immutable original bytes, loaded once.
|
|
// patch — sparse overlay of modified bytes keyed by offset.
|
|
// working — logical view = source with patch applied on read.
|
|
//
|
|
// Reads happen through windowed Range() calls so neither the editor
|
|
// nor analysis services need to materialise the whole file.
|
|
package document
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// ChunkSize is the granularity at which source bytes are exposed to
|
|
// analysis services. It is independent of the view row width.
|
|
const ChunkSize = 64 * 1024
|
|
|
|
// Document represents one loaded artefact. A Session may hold many.
|
|
type Document struct {
|
|
mu sync.RWMutex
|
|
name string
|
|
source []byte // original immutable content
|
|
path string // optional immutable file-backed source
|
|
size int64
|
|
patch map[int64]byte
|
|
history []EditOp
|
|
redo []EditOp
|
|
cursor int
|
|
}
|
|
|
|
// EditOp records a single byte-level mutation for undo/redo.
|
|
type EditOp struct {
|
|
Offset int64
|
|
Old byte
|
|
New byte
|
|
Kind string // "set", "fill", "xor"
|
|
}
|
|
|
|
// New creates a Document from raw source bytes. The slice is not
|
|
// copied; callers must not mutate it after handoff.
|
|
func New(name string, src []byte) *Document {
|
|
return &Document{
|
|
name: name,
|
|
source: src,
|
|
size: int64(len(src)),
|
|
patch: make(map[int64]byte),
|
|
}
|
|
}
|
|
|
|
// Open creates a Document backed by an immutable file on disk. Source
|
|
// bytes are read on demand through Range rather than loaded eagerly.
|
|
func Open(path string) (*Document, error) {
|
|
st, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if st.IsDir() {
|
|
return nil, errors.New("document path is a directory")
|
|
}
|
|
return &Document{
|
|
name: filepath.Base(path),
|
|
path: path,
|
|
size: st.Size(),
|
|
patch: make(map[int64]byte),
|
|
}, nil
|
|
}
|
|
|
|
// Name returns the document's display name.
|
|
func (d *Document) Name() string {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.name
|
|
}
|
|
|
|
// Size returns the logical document length in bytes.
|
|
func (d *Document) Size() int64 {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.size
|
|
}
|
|
|
|
// At returns the logical byte at offset (patch takes precedence).
|
|
func (d *Document) At(offset int64) (byte, error) {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
if offset < 0 || offset >= d.size {
|
|
return 0, errors.New("offset out of range")
|
|
}
|
|
if b, ok := d.patch[offset]; ok {
|
|
return b, nil
|
|
}
|
|
return d.sourceByteLocked(offset)
|
|
}
|
|
|
|
// Range copies [start, start+length) into dst. It applies the patch
|
|
// overlay. Callers get a fresh slice they are free to retain.
|
|
func (d *Document) Range(start, length int64) ([]byte, error) {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
n := d.size
|
|
if start < 0 || start > n {
|
|
return nil, errors.New("range start out of bounds")
|
|
}
|
|
if length < 0 {
|
|
return nil, errors.New("negative range")
|
|
}
|
|
if length > n-start {
|
|
length = n - start
|
|
}
|
|
out, err := d.sourceRangeLocked(start, length)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(d.patch) > 0 {
|
|
end := start + length
|
|
for off, b := range d.patch {
|
|
if off >= start && off < end {
|
|
out[off-start] = b
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// IsDirty reports whether any byte has been patched.
|
|
func (d *Document) IsDirty() bool {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return len(d.patch) > 0
|
|
}
|
|
|
|
// Set patches a single byte. Returns the previous value.
|
|
func (d *Document) Set(offset int64, b byte) (byte, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if offset < 0 || offset >= d.size {
|
|
return 0, errors.New("offset out of range")
|
|
}
|
|
orig, err := d.sourceByteLocked(offset)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var old byte
|
|
if v, ok := d.patch[offset]; ok {
|
|
old = v
|
|
} else {
|
|
old = orig
|
|
}
|
|
if b == orig {
|
|
delete(d.patch, offset)
|
|
} else {
|
|
d.patch[offset] = b
|
|
}
|
|
d.history = append(d.history, EditOp{Offset: offset, Old: old, New: b, Kind: "set"})
|
|
d.redo = nil
|
|
return old, nil
|
|
}
|
|
|
|
// Fill patches [start,start+length) with b.
|
|
func (d *Document) Fill(start, length int64, b byte) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.validateRangeLocked("fill", start, length); err != nil {
|
|
return err
|
|
}
|
|
orig, err := d.sourceRangeLocked(start, length)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := int64(0); i < length; i++ {
|
|
off := start + i
|
|
origB := orig[i]
|
|
var old byte
|
|
if v, ok := d.patch[off]; ok {
|
|
old = v
|
|
} else {
|
|
old = origB
|
|
}
|
|
if b == origB {
|
|
delete(d.patch, off)
|
|
} else {
|
|
d.patch[off] = b
|
|
}
|
|
d.history = append(d.history, EditOp{Offset: off, Old: old, New: b, Kind: "fill"})
|
|
}
|
|
d.redo = nil
|
|
return nil
|
|
}
|
|
|
|
// XOR patches [start,start+length) with a repeating key.
|
|
func (d *Document) XOR(start, length int64, key []byte) error {
|
|
if len(key) == 0 {
|
|
return errors.New("empty key")
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.validateRangeLocked("xor", start, length); err != nil {
|
|
return err
|
|
}
|
|
orig, err := d.sourceRangeLocked(start, length)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := int64(0); i < length; i++ {
|
|
off := start + i
|
|
origB := orig[i]
|
|
var cur byte
|
|
if v, ok := d.patch[off]; ok {
|
|
cur = v
|
|
} else {
|
|
cur = origB
|
|
}
|
|
newB := cur ^ key[i%int64(len(key))]
|
|
if newB == origB {
|
|
delete(d.patch, off)
|
|
} else {
|
|
d.patch[off] = newB
|
|
}
|
|
d.history = append(d.history, EditOp{Offset: off, Old: cur, New: newB, Kind: "xor"})
|
|
}
|
|
d.redo = nil
|
|
return nil
|
|
}
|
|
|
|
// Copy copies [src,src+length) onto [dst,dst+length) as a patch.
|
|
func (d *Document) Copy(src, length, dst int64) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err := d.validateRangeLocked("copy source", src, length); err != nil {
|
|
return err
|
|
}
|
|
if err := d.validateRangeLocked("copy destination", dst, length); err != nil {
|
|
return err
|
|
}
|
|
buf, err := d.workingRangeLocked(src, length)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dstOrig, err := d.sourceRangeLocked(dst, length)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := int64(0); i < length; i++ {
|
|
off := dst + i
|
|
origB := dstOrig[i]
|
|
var cur byte
|
|
if v, ok := d.patch[off]; ok {
|
|
cur = v
|
|
} else {
|
|
cur = origB
|
|
}
|
|
b := buf[i]
|
|
if b == origB {
|
|
delete(d.patch, off)
|
|
} else {
|
|
d.patch[off] = b
|
|
}
|
|
d.history = append(d.history, EditOp{Offset: off, Old: cur, New: b, Kind: "fill"})
|
|
}
|
|
d.redo = nil
|
|
return nil
|
|
}
|
|
|
|
// Undo reverts the last edit. Returns false if there is no history.
|
|
func (d *Document) Undo() bool {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if len(d.history) == 0 {
|
|
return false
|
|
}
|
|
op := d.history[len(d.history)-1]
|
|
d.history = d.history[:len(d.history)-1]
|
|
orig, err := d.sourceByteLocked(op.Offset)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if op.Old == orig {
|
|
delete(d.patch, op.Offset)
|
|
} else {
|
|
d.patch[op.Offset] = op.Old
|
|
}
|
|
d.redo = append(d.redo, op)
|
|
return true
|
|
}
|
|
|
|
// Redo reapplies the last undone edit. Returns false if there is no redo.
|
|
func (d *Document) Redo() bool {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if len(d.redo) == 0 {
|
|
return false
|
|
}
|
|
op := d.redo[len(d.redo)-1]
|
|
d.redo = d.redo[:len(d.redo)-1]
|
|
orig, err := d.sourceByteLocked(op.Offset)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if op.New == orig {
|
|
delete(d.patch, op.Offset)
|
|
} else {
|
|
d.patch[op.Offset] = op.New
|
|
}
|
|
d.history = append(d.history, op)
|
|
return true
|
|
}
|
|
|
|
// Revert drops all patches and history.
|
|
func (d *Document) Revert() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.patch = make(map[int64]byte)
|
|
d.history = nil
|
|
d.redo = nil
|
|
}
|
|
|
|
// Patches returns a snapshot of the patch overlay.
|
|
func (d *Document) Patches() map[int64]byte {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
out := make(map[int64]byte, len(d.patch))
|
|
for k, v := range d.patch {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HistoryLen returns the number of recorded edits.
|
|
func (d *Document) HistoryLen() int {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return len(d.history)
|
|
}
|
|
|
|
// RedoLen returns the number of redoable edits.
|
|
func (d *Document) RedoLen() int {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return len(d.redo)
|
|
}
|
|
|
|
// ForEachChunk yields chunks covering [start,start+length) with the
|
|
// working-copy bytes visible. It is suitable for analysis passes that
|
|
// want bounded memory use.
|
|
func (d *Document) ForEachChunk(start, length int64, fn func(off int64, chunk []byte) error) error {
|
|
d.mu.RLock()
|
|
n := d.size
|
|
d.mu.RUnlock()
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := start + length
|
|
if length < 0 || end > n {
|
|
end = n
|
|
}
|
|
off := start
|
|
for off < end {
|
|
chunkEnd := off + ChunkSize
|
|
if chunkEnd > end {
|
|
chunkEnd = end
|
|
}
|
|
buf, err := d.Range(off, chunkEnd-off)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(off, buf); err != nil {
|
|
return err
|
|
}
|
|
off = chunkEnd
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Document) sourceByteLocked(offset int64) (byte, error) {
|
|
buf, err := d.sourceRangeLocked(offset, 1)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(buf) == 0 {
|
|
return 0, errors.New("offset out of range")
|
|
}
|
|
return buf[0], nil
|
|
}
|
|
|
|
func (d *Document) workingRangeLocked(start, length int64) ([]byte, error) {
|
|
buf, err := d.sourceRangeLocked(start, length)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for off, b := range d.patch {
|
|
if off >= start && off < start+length {
|
|
buf[off-start] = b
|
|
}
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
func (d *Document) sourceRangeLocked(start, length int64) ([]byte, error) {
|
|
if err := d.validateRangeLocked("range", start, length); err != nil {
|
|
return nil, err
|
|
}
|
|
if length == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
if d.path == "" {
|
|
out := make([]byte, length)
|
|
copy(out, d.source[start:start+length])
|
|
return out, nil
|
|
}
|
|
f, err := os.Open(d.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
out := make([]byte, length)
|
|
_, err = f.ReadAt(out, start)
|
|
if errors.Is(err, io.EOF) {
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (d *Document) validateRangeLocked(label string, start, length int64) error {
|
|
if start < 0 || start > d.size || length < 0 || length > d.size-start {
|
|
return fmt.Errorf("%s range invalid", label)
|
|
}
|
|
return nil
|
|
}
|