waveterm/pkg/util/readutil/readutil.go

199 lines
5.4 KiB
Go
Raw Normal View History

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package readutil
import (
"bufio"
"fmt"
"io"
"os"
)
const (
StopReasonBOF = "bof"
StopReasonEOF = "eof"
StopReasonReadLimit = "read_limit"
)
// ReadLines reads lines from the reader, optionally skipping the first skipLines lines.
// If lineCount is 0, no line limit is applied. If readLimit is 0, no byte limit is applied.
// Stops when either limit is reached or EOF.
// Returns lines (with trailing newlines), stop reason, and error.
// Stop reason is StopReasonEOF when EOF is reached, StopReasonReadLimit when byte limit is reached,
// or empty string for natural returns (line count limit or no limits applied).
func ReadLines(reader io.Reader, lineCount int, skipLines int, readLimit int) ([]string, string, error) {
bufReader := bufio.NewReader(reader)
lines := make([]string, 0)
bytesRead := 0
skippedLines := 0
for {
line, err := bufReader.ReadString('\n')
if len(line) > 0 {
bytesRead += len(line)
if skippedLines < skipLines {
skippedLines++
} else {
lines = append(lines, line)
if lineCount > 0 && len(lines) >= lineCount {
return lines, "", nil
}
}
if readLimit > 0 && bytesRead >= readLimit {
return lines, StopReasonReadLimit, nil
}
}
if err != nil {
if err == io.EOF {
return lines, StopReasonEOF, nil
}
return nil, "", err
}
}
}
// readLastNLineOffsets reads all line offsets from the reader, keeping only the last maxLines in a sliding window.
// keepFirst indicates whether offset 0 should be included (true if starting from file beginning).
// Returns the offsets and the total number of lines found.
func ReadLastNLineOffsets(rs io.ReadSeeker, maxLines int, keepFirst bool) ([]int64, int, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, 0, err
}
var offsets []int64
reader := bufio.NewReader(rs)
var currentPos int64 = 0
totalLines := 0
if keepFirst {
offsets = append(offsets, 0)
totalLines = 1
}
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
currentPos += int64(len(line))
offsets = append(offsets, currentPos)
totalLines++
// Keep maxLines+1 for sliding window (extra slot for EOF position)
if len(offsets) > maxLines+1 {
offsets = offsets[1:]
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, 0, err
}
}
// Trim the final EOF offset if we have one
if len(offsets) > 0 {
offsets = offsets[:len(offsets)-1]
totalLines--
}
return offsets, totalLines, nil
}
// readTailLinesInternal reads the last lineCount lines from the reader, excluding the last lineOffset lines.
// For example, lineCount=10 and lineOffset=5 would return lines -15 through -6 (the 10 lines before the last 5).
// keepFirst indicates whether the first line should be kept (true if starting at file position 0, false otherwise).
// Returns the lines (with trailing newlines), a hasMore flag, and any error.
// hasMore is true if there are lines before our window (didn't hit BOF), false if we read from the very beginning.
func readTailLinesInternal(rs io.ReadSeeker, lineCount int, lineOffset int, keepFirst bool) ([]string, bool, error) {
maxOffsets := lineCount + lineOffset
offsets, totalLines, err := ReadLastNLineOffsets(rs, maxOffsets, keepFirst)
if err != nil {
return nil, false, err
}
if totalLines <= lineOffset {
return []string{}, false, nil
}
linesToRead := lineCount
if totalLines-lineOffset < lineCount {
linesToRead = totalLines - lineOffset
}
startIdx := len(offsets) - lineOffset - linesToRead
hasMore := totalLines > lineCount+lineOffset
if _, err := rs.Seek(offsets[startIdx], io.SeekStart); err != nil {
return nil, false, err
}
lines, _, err := ReadLines(rs, linesToRead, 0, 0)
if err != nil {
return nil, false, err
}
return lines, hasMore, nil
}
// ReadTailLines reads the last lineCount lines from a file, excluding the last lineOffset lines.
// It progressively reads larger windows from the end of the file (starting at 1MB, doubling up to readLimit)
// until it finds enough lines or reaches the limit. Returns the lines, stop reason, and any error.
// Stop reason is StopReasonBOF when beginning of file is reached, StopReasonReadLimit when byte limit is reached,
// or empty string for natural completion (found requested line count).
func ReadTailLines(file *os.File, lineCount int, lineOffset int, readLimit int64) ([]string, string, error) {
if readLimit <= 0 {
return nil, "", fmt.Errorf("ReadTailLines readLimit must be positive, got %d", readLimit)
}
fileInfo, err := file.Stat()
if err != nil {
return nil, "", err
}
fileSize := fileInfo.Size()
readBytes := int64(1024 * 1024)
if readLimit < readBytes {
readBytes = readLimit
}
for {
startPos := fileSize - readBytes
if startPos < 0 {
startPos = 0
readBytes = fileSize
}
sectionReader := io.NewSectionReader(file, startPos, readBytes)
keepFirst := startPos == 0
lines, hasMoreInWindow, err := readTailLinesInternal(sectionReader, lineCount, lineOffset, keepFirst)
if err != nil {
return nil, "", err
}
if len(lines) == lineCount {
hasMore := startPos > 0 || hasMoreInWindow
if !hasMore {
return lines, StopReasonBOF, nil
}
return lines, "", nil
}
if readBytes >= readLimit || readBytes >= fileSize {
if startPos > 0 {
return lines, StopReasonReadLimit, nil
}
return lines, StopReasonBOF, nil
}
readBytes *= 2
if readBytes > readLimit {
readBytes = readLimit
}
}
}