Pad/internal/editor/chunked_buffer.go
Greg Pomerantz 9b782190fa editor: honor key.EditEvent.Range in HandleReplaceRange (IME swipe/autocorrect)
The IME commit path (swipe-to-type, autocorrect replacement) sends a
key.EditEvent with a Range that the editor ignored, causing the replaced
region to be duplicated. Add Logic.HandleReplaceRange which deletes the
rune range [start,end) and inserts text, converting rune indices to byte
offsets (string path: utf8.DecodeRuneInString; chunked path: leading-byte
scan). HandleKeyDown now routes key.EditEvent through it.

Also fixes two pre-existing ChunkedBuffer correctness bugs the tests
exposed:
- Delete mis-computed the per-chunk end using a shrinking 'remaining'
  instead of the absolute end, corrupting multi-chunk deletes.
- FullContent derived its chunk bound solely from fileLen, truncating
  in-memory chunks grown past the old bound by an insert.

Adds ime_range_test.go covering insert/replace/delete, unicode, chunked,
and swapped bounds. go test -race ./... green.
2026-08-16 02:18:44 -04:00

709 lines
22 KiB
Go

package editor
import (
"bytes"
"fmt"
"sort"
"pad/internal/io/pool"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
const (
DefaultChunkSize = 64 * 1024 // 64 KB
)
// ChunkedBuffer provides chunked access to a file's content.
// Only chunks near the cursor or viewport are kept in memory.
type ChunkedBuffer struct {
filename string
chunkSize int // e.g., 64 * 1024 (64 KB)
fileLen int64 // total file length (known from stat)
chunks map[int][]byte // chunkIndex → []byte
dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reading chunks
basePath string // base path for file resolution
// line index is built asynchronously
LineIndex *types.LineIndex
// workerPool is set by the logic goroutine after the buffer is created.
// Used for async prefetch of chunks.
workerPool *pool.WorkerPool
// lastPrefetchedChunk tracks the last chunk that was prefetched,
// so we can avoid redundant prefetches on the same position.
lastPrefetchedChunk int
// dirtyChunks tracks which individual chunks have been modified
// since they were last persisted to disk. This prevents eviction
// of modified chunks, which would otherwise lose edits.
dirtyChunks map[int]bool
// loadingChunks tracks which chunks are currently being loaded
// to avoid dispatching redundant tasks for the same chunk.
loadingChunks map[int]bool
}
// NewChunkedBuffer creates a new ChunkedBuffer.
func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePath string) *ChunkedBuffer {
if chunkSize <= 0 {
chunkSize = DefaultChunkSize
}
return &ChunkedBuffer{
filename: filename,
chunkSize: chunkSize,
chunks: make(map[int][]byte),
dirtyChunks: make(map[int]bool),
loadingChunks: make(map[int]bool),
FS: fs,
basePath: basePath,
}
}
// SetFileSize sets the total file length.
func (cb *ChunkedBuffer) SetFileSize(length int64) {
if !cb.dirty {
cb.fileLen = length
}
}
// FileLen returns the total file length.
func (cb *ChunkedBuffer) FileLen() int64 {
return cb.fileLen
}
// Filename returns the filename.
func (cb *ChunkedBuffer) Filename() string {
return cb.filename
}
// ChunkSize returns the chunk size.
func (cb *ChunkedBuffer) ChunkSize() int {
return cb.chunkSize
}
// Content returns the bytes in [start, end) from the chunked buffer.
// It loads missing chunks on demand.
func (cb *ChunkedBuffer) Content(start, end int) string {
if cb.fileLen == 0 {
return ""
}
// Clamp range to file bounds
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if start >= end {
return ""
}
startChunk := start / cb.chunkSize
endChunk := (end - 1) / cb.chunkSize
var buf bytes.Buffer
for i := startChunk; i <= endChunk; i++ {
chunk, ok := cb.chunks[i]
if !ok {
// If chunk is not loaded, initiate async load and skip this chunk.
// This keeps the UI responsive while chunks are loaded in the background.
if cb.workerPool != nil && !cb.loadingChunks[i] {
cb.loadingChunks[i] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, i, cb.FS),
)
}
continue
}
chunkStart := i * cb.chunkSize
chunkEnd := chunkStart + len(chunk)
segStart := max(start, chunkStart) - chunkStart
segEnd := min(end, chunkEnd) - chunkStart
if segStart < segEnd {
buf.Write(chunk[segStart:segEnd])
}
}
return buf.String()
}
// chunkSync returns the bytes of chunk idx, synchronously. It prefers the
// in-memory (possibly dirty) copy and only reads from disk for clean, evicted
// chunks. Calling loadChunk directly on a dirty chunk would clobber the
// in-memory edit with stale disk data, so the map is checked first.
func (cb *ChunkedBuffer) chunkSync(idx int) ([]byte, error) {
if chunk, ok := cb.chunks[idx]; ok {
return chunk, nil
}
if cb.dirtyChunks[idx] {
return nil, fmt.Errorf("chunk %d is dirty but not in memory", idx)
}
return cb.loadChunk(idx)
}
// RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the
// buffer. The IME addresses text in rune indices while the buffer is
// byte-based, so this bridges the two. It scans chunk by chunk and stops as
// soon as the n-th rune is found, so it reads only up to the caret (a few
// hundred KB for a typical position) rather than the whole file. If n is at
// or past the end, it returns the file length. On a chunk read error it
// returns the byte offset scanned so far (a best-effort position).
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
if n <= 0 {
return 0
}
fileLen := int(cb.fileLen)
if fileLen == 0 {
return 0
}
runes := 0
offset := 0
for offset < fileLen {
end := offset + cb.chunkSize
if end > fileLen {
end = fileLen
}
idx := offset / cb.chunkSize
chunk, err := cb.chunkSync(idx)
if err != nil {
// Can't count past a broken chunk; stop here.
fmt.Printf("RuneIndexToByte: %v\n", err)
return offset + len(chunk)
}
for i := 0; i < len(chunk); i++ {
b := chunk[i]
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
// lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
if b < 0x80 || b >= 0xC0 {
if runes == n {
return offset + i
}
runes++
}
}
offset = end
}
return fileLen
}
// FullContent reconstructs the entire file content from loaded or re-read chunks.
// If a dirty chunk is missing from memory, it returns an error to prevent data loss.
func (cb *ChunkedBuffer) FullContent() (string, error) {
if cb.fileLen == 0 && len(cb.chunks) == 0 {
return "", nil
}
// Iterate up to the larger of the fileLen-derived chunk count and the
// highest in-memory chunk index. Deriving the bound solely from fileLen
// would truncate in-memory chunks that an insert grew past the old bound.
numChunks := 0
if cb.fileLen > 0 {
numChunks = int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
}
for i := range cb.chunks {
if i+1 > numChunks {
numChunks = i + 1
}
}
var buf bytes.Buffer
for i := 0; i < numChunks; i++ {
var chunk []byte
var err error
if loadedChunk, ok := cb.chunks[i]; ok {
chunk = loadedChunk
} else {
// If chunk is dirty but not in memory, we have a problem.
if cb.dirtyChunks[i] {
return "", fmt.Errorf("critical error: dirty chunk %d is missing from memory", i)
}
// Re-read from disk if not in memory
chunk, err = cb.loadChunk(i)
if err != nil {
fmt.Printf("Error re-reading chunk %d for file %s during FullContent: %v\n", i, cb.filename, err)
continue // Skip this chunk on error
}
}
buf.Write(chunk)
}
return buf.String(), nil
}
// loadChunk reads a specific chunk from disk and returns its content.
// It also stores the chunk in the 'chunks' map.
// Uses FS.ReadFileAt to read only the chunk range (not the entire file).
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
start := idx * cb.chunkSize
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
if err != nil {
return nil, fmt.Errorf("failed to read chunk %d: %w", idx, err)
}
cb.chunks[idx] = chunk // Store the loaded chunk
return chunk, nil
}
// LoadChunk explicitly loads a chunk and prefetch adjacent ones.
func (cb *ChunkedBuffer) LoadChunk(idx int) {
if _, ok := cb.chunks[idx]; !ok {
_, err := cb.loadChunk(idx)
if err != nil {
fmt.Printf("Error loading chunk %d: %v\n", idx, err)
}
}
// Prefetch adjacent chunks
cb.Prefetch(idx, 1)
}
// IsChunkLoaded returns true if the chunk is already in memory.
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
_, ok := cb.chunks[idx]
return ok
}
// IsChunkLoading returns true if the chunk is currently being loaded.
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
return cb.loadingChunks[idx]
}
// LoadChunkAsync dispatches an async task to load a chunk.
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
if cb.workerPool != nil && !cb.loadingChunks[idx] {
cb.loadingChunks[idx] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
)
}
}
// SetWorkerPool sets the worker pool for async chunk loading.
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
cb.workerPool = wp
}
// LastPrefetchedChunk returns the last chunk that was prefetched.
// Used by EditorLayout to avoid redundant prefetches.
func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
return cb.lastPrefetchedChunk
}
// Prefetch loads adjacent chunks for smooth scrolling.
// radius is the number of chunks to load on each side.
// Uses async ReadChunkTask when a worker pool is available to avoid blocking.
func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
for i := centerChunk - radius; i <= centerChunk + radius; i++ {
if i >= 0 && i < numChunks {
// Check if chunk is in memory or already loading.
if _, ok := cb.chunks[i]; !ok && !cb.loadingChunks[i] {
if cb.workerPool != nil {
// Mark as loading before dispatching to prevent redundant requests
cb.loadingChunks[i] = true
// Dispatch async read chunk task
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, i, cb.FS),
)
} else {
// Fallback: synchronous load (should only happen during testing)
_, err := cb.loadChunk(i)
if err != nil {
fmt.Printf("Error prefetching chunk %d: %v\n", i, err)
}
}
}
}
}
cb.lastPrefetchedChunk = centerChunk
}
// EvictFarChunks removes chunks that are too far from the cursor.
// radius is the number of chunks to keep around the cursor.
// Dirty chunks (modified since the last disk write) are NEVER evicted,
// even if they are far from the cursor, to prevent data loss.
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
if cb.fileLen == 0 {
return
}
cursorChunk := cursorPos / cb.chunkSize
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
for i := range cb.chunks {
// NEVER evict dirty chunks — they contain unsaved modifications
if cb.dirtyChunks[i] {
continue
}
if i < cursorChunk-radius || i > cursorChunk+radius {
// Check if chunk index is within valid range before deleting
if i >= 0 && i < numChunks {
delete(cb.chunks, i)
}
}
}
}
// Insert inserts text at a given position.
func (cb *ChunkedBuffer) Insert(pos int, text string) {
if len(text) == 0 {
return
}
// Ensure the chunk containing pos is loaded
chunkIdx := pos / cb.chunkSize
// Ensure we don't try to insert beyond the current fileLen if it's not a new file
if pos > int(cb.fileLen) && cb.fileLen > 0 {
pos = int(cb.fileLen) // clamp insertion point to end of file
}
if pos < 0 {
pos = 0
}
// Load chunk if it doesn't exist, or if pos is at the very beginning of a non-loaded chunk
if _, ok := cb.chunks[chunkIdx]; !ok {
// If inserting at the start of a chunk, we need to load it.
// If inserting past the end of the file, we might create new chunks.
// For now, assume loadChunk handles cases where pos is beyond current fileLen by reading up to fileLen.
_, err := cb.loadChunk(chunkIdx) // This is blocking and might be problematic
if err != nil {
fmt.Printf("Error loading chunk %d for insert: %v\n", chunkIdx, err)
return
}
}
chunk := cb.chunks[chunkIdx]
offsetInChunk := pos - chunkIdx*cb.chunkSize
// Ensure offsetInChunk is valid for the loaded chunk
if offsetInChunk > len(chunk) {
// This can happen if we are inserting past the end of the loaded chunk,
// which might be due to fileLen not being updated or insertion into new territory.
// For now, we'll pad the chunk if needed. This needs more robust handling.
padding := make([]byte, offsetInChunk-len(chunk))
chunk = append(chunk, padding...)
}
// Insert into the chunk
newChunk := make([]byte, len(chunk)+len(text))
copy(newChunk, chunk[:offsetInChunk])
copy(newChunk[offsetInChunk:], text)
copy(newChunk[offsetInChunk+len(text):], chunk[offsetInChunk:])
cb.chunks[chunkIdx] = newChunk
cb.fileLen += int64(len(text))
cb.dirty = true
cb.dirtyChunks[chunkIdx] = true
// If insertion spans chunk boundary, it might require merging chunks.
// This is complex and might involve resizing subsequent chunks and potentially
// re-reading them. For now, we defer complex merge logic.
// The plan mentions maybeMergeChunk, which would handle this.
// cb.maybeMergeChunk(chunkIdx)
}
// Delete deletes n bytes starting at pos.
func (cb *ChunkedBuffer) Delete(pos, n int) {
if n <= 0 {
return
}
// Clamp pos and n to valid range
if pos < 0 {
pos = 0
}
if pos >= int(cb.fileLen) {
return // Nothing to delete
}
if pos+n > int(cb.fileLen) {
n = int(cb.fileLen) - pos
}
startChunk := pos / cb.chunkSize
endChunk := (pos + n - 1) / cb.chunkSize
// Load all affected chunks
for i := startChunk; i <= endChunk; i++ {
if i < 0 {
continue
}
if _, ok := cb.chunks[i]; !ok {
_, err := cb.loadChunk(i)
if err != nil {
fmt.Printf("Error loading chunk %d for delete: %v\n", i, err)
return // Abort delete on error
}
}
}
// Perform deletion across all affected chunks. The deletion region is the
// absolute byte range [pos, pos+n); each chunk we visit removes its
// overlap with that range. (Using a shrinking "remaining" to derive the
// per-chunk end was wrong: it mis-computed the end for every chunk after
// the first, corrupting multi-chunk deletes.)
absEnd := pos + n
remaining := n
for i := startChunk; i <= endChunk && remaining > 0; i++ {
if i < 0 {
continue
}
chunk := cb.chunks[i]
if chunk == nil {
continue
}
currentChunkStart := i * cb.chunkSize
currentChunkEnd := currentChunkStart + len(chunk)
// Overlap of [pos, absEnd) with this chunk's absolute span.
delStart := max(pos, currentChunkStart)
delEnd := min(absEnd, currentChunkEnd)
if delStart >= delEnd {
continue // Deletion range doesn't overlap this chunk
}
localStart := delStart - currentChunkStart
localEnd := delEnd - currentChunkStart
// Perform deletion within the chunk
cb.chunks[i] = append(chunk[:localStart], chunk[localEnd:]...)
remaining -= delEnd - delStart
// Mark this chunk as dirty so it won't be evicted
cb.dirtyChunks[i] = true
}
// Update file length
cb.fileLen -= int64(n)
if cb.fileLen < 0 {
cb.fileLen = 0
}
cb.dirty = true
}
// VisibleByteRange returns the byte range [start, end) of content
// visible in the viewport, given the current scroll offset and viewport height.
func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) {
// If wrapping and we have visual line data, use it for precise calculation
if wordWrap && len(layout.VisualLineStarts) > 0 {
// Calculate which visual line should be at the top based on scroll offset
visualLine := int(scrollOffset / lineHeight)
if visualLine >= len(layout.VisualLineStarts) {
visualLine = len(layout.VisualLineStarts) - 1
}
start = layout.VisualLineStarts[visualLine] + byteOffset
fmt.Printf("VisibleByteRange: visualLine = %d start = %d\n", visualLine, start)
// Calculate end: enough content to fill viewport + buffer
linesInViewport := int(viewportHeight / lineHeight) + 2
endLine := visualLine + linesInViewport
if endLine >= len(layout.VisualLineStarts) {
end = int(cb.fileLen)
} else {
end = layout.VisualLineStarts[endLine]
}
// Clamp to file bounds
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if start >= end {
end = start + 1000 // minimum buffer
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
}
return start, end, visualLine
}
// Fallback to LineIndex (logical lines) or estimate if:
// 1. Not word wrapping
// 2. No visual index available
if cb.LineIndex == nil {
start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
return start, end, 0
}
start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight)
return start, end, 0
}
// visibleByteRangeEstimate approximates the visible byte range using
// heuristic estimates. Used when the line index is not yet available.
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
// This is a rough estimation. A proper implementation would need actual line height.
// For simplicity, assuming a fixed line height based on FontSize.
// This requires access to theme or font metrics, which is not directly available here.
// As a fallback, let's use a simplified calculation based on estimated lines and average bytes per line.
// Use the editor's line height constant for consistency.
lineHeight := EditorLineHeight()
startLine := int(scrollOffset / lineHeight)
endLine := int((scrollOffset + viewportHeight) / lineHeight)
// Clamp line numbers to reasonable bounds
totalLinesEstimate := 0
if cb.fileLen > 0 {
totalLinesEstimate = int(cb.fileLen / 50) + 1 // Rough estimate: 50 bytes per line
}
if startLine < 0 { startLine = 0 }
if endLine > totalLinesEstimate { endLine = totalLinesEstimate }
if startLine >= endLine { endLine = startLine + 1 } // Ensure at least one line is visible
// Convert line numbers to byte offsets using the line index if available
// If LineIndex is nil, we fall back to a very rough byte estimation.
if cb.LineIndex != nil {
// Use LineIndex if it exists
if startLine < len(cb.LineIndex.Offsets) {
start = int(cb.LineIndex.Offsets[startLine])
} else {
// If startLine is beyond index, estimate based on last known offset and average line length
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) -1)
start = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate
}
if endLine < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine])
} else {
// If endLine is beyond index, estimate
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) -1)
end = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate
}
} else {
// Rough byte estimation if no LineIndex
start = startLine * 50 // rough estimate: 50 bytes per line
end = endLine * 50
}
// Clamp to file bounds
if cb.fileLen > 0 {
if start < 0 { start = 0 }
if end > int(cb.fileLen) { end = int(cb.fileLen) }
if end <= start { end = start + cb.chunkSize } // Ensure at least one chunk's worth if range is invalid
} else {
start = 0
end = 0 // Empty file
}
return start, end
}
// visibleByteRangePrecise uses the LineIndex to find the exact byte range.
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
// Should not happen if called after LineIndex is available, but as a safeguard:
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
}
// Use the editor's line height constant for consistency.
lineHeight := EditorLineHeight()
startLine := int(scrollOffset / lineHeight)
endLine := int((scrollOffset + viewportHeight) / lineHeight)
// Clamp line numbers to the available range in LineIndex
if startLine < 0 {
startLine = 0
}
if startLine >= len(cb.LineIndex.Offsets) {
startLine = len(cb.LineIndex.Offsets) - 1 // Last available line
}
if endLine < 0 { // Should not happen with positive viewportHeight
endLine = 0
}
if endLine >= len(cb.LineIndex.Offsets) {
endLine = len(cb.LineIndex.Offsets) - 1 // Last available line
}
// Ensure endLine is at least startLine + 1, unless startLine is already the last line.
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
endLine = startLine + 1
}
start = int(cb.LineIndex.Offsets[startLine])
// The end byte offset is the start of the *next* line after the visible range.
// If endLine is the last line in the index, the end byte offset is the file length.
if endLine+1 < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine+1])
} else {
end = int(cb.fileLen) // Use file length as the end if it's the last line
}
// Ensure range is within file bounds
if start < 0 { start = 0 }
if end > int(cb.fileLen) { end = int(cb.fileLen) }
if end <= start {
// If somehow the range is invalid, return a minimal valid range,
// e.g., start of the start line to a bit past it, or just end of file.
if start < int(cb.fileLen) {
end = min(start+cb.chunkSize, int(cb.fileLen)) // At least one chunk or up to file end
} else {
end = start // If start is already at file end, range is empty
}
}
return start, end
}
// maybeMergeChunk is a placeholder for logic that consolidates chunks if they become too small
// or if edits cause fragmentation. This is complex and deferred.
func (cb *ChunkedBuffer) maybeMergeChunk(chunkIdx int) {
// Placeholder for future implementation
// This would involve checking chunk sizes and potentially merging adjacent chunks.
}
// UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or delete.
// offsetShift is the number of bytes inserted (positive) or deleted (negative).
// editPos is the byte position where the edit occurred.
// This is a partial update: only offsets after editPos are shifted.
func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) {
if cb.LineIndex == nil {
return
}
// Find the first offset that needs updating using binary search.
// All offsets >= editPos need to be shifted by offsetShift.
idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool {
return int(cb.LineIndex.Offsets[i]) >= editPos
})
if idx == 0 {
idx = 1
}
for i := idx; i < len(cb.LineIndex.Offsets); i++ {
cb.LineIndex.Offsets[i] += int32(offsetShift)
}
// Update the file size stamp
cb.LineIndex.Size += int64(offsetShift)
if cb.LineIndex.Size < 0 {
cb.LineIndex.Size = 0
}
}
// Helper function for max
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Helper function for min
func min(a, b int) int {
if a < b {
return a
}
return b
}