file_lines.pony

use "buffered"

class FileLines is Iterator[String iso^]
  """
  Iterate over the lines in a file.

  Returns lines without trailing line breaks.
  Advances the file cursor to the end of each line returned from `next`.

  This class buffers the file contents to accumulate full lines. If the file
  does not contain linebreaks, the whole file content is read and buffered, which
  might exceed memory resources. Take care.
  """
  let _reader: Reader = Reader
  let _file: File
  let _min_read_size: USize
  var _last_line_length: USize
  var _buffer_cursor: USize
    """Internal cursor for keeping track until where in the file we already buffered."""
  var _cursor: USize
    """Keeps track of the file position we update after every returned line."""
  var _has_next: Bool

  new create(file: File, min_read_size: USize = 256) =>
    """
    Create a FileLines instance on a given file.

    This instance returns lines from the position of the given `file`
    at the time this constructor is called. Later manipulation of the file position
    is not accounted for. As a result iterating with this class will always return the full
    file content without gaps or repeated lines.

    `min_read_size` determines the minimum amount of bytes to read from the file
    in one go. This class keeps track of the line lengths in the current file
    and uses the length of the last line as amount of bytes to read next, but it
    will never read less than `min_read_size`.
    """
    _file = file
    _buffer_cursor = _file.position()
    _cursor = _file.position()
    _min_read_size = min_read_size
    _last_line_length = min_read_size
    _has_next = _file.valid()

  fun ref has_next(): Bool =>
    _has_next

  fun ref next(): String iso^ ? =>
    """
    Returns the next line in the file.
    """
    while true do
      try
        return _read_line()?
      else
        if not _fill_buffer() then
          // nothing to read from file, we can savely exit here
          break
        end
      end
    end
    _has_next = false
    if _reader.size() > 0 then
      // don't forget the last line
      _read_last_line()?
    else
      // nothing to return, we can only error here
      error
    end

  fun ref _read_line(): String iso^ ? =>
    let line = _reader.line(where keep_line_breaks = true)?
    let len = line.size()
    _last_line_length = len

    // advance the cursor to the end of the returned line
    _inc_public_file_cursor(len)

    // strip trailing line break
    line.truncate(
      len - if (len >= 2) and (line.at_offset(-2)? == '\r') then 2 else 1 end)
    consume line

  fun ref _fill_buffer(): Bool =>
    """
    read from file and fill the reader-buffer.

    Returns `true` if data could be read from the file.

    After a successful reading operation `_buffer_cursor` is updated.
    """
    var result = true
    // get back to position of last line
    let current_pos = _file.position()
    _file.seek_start(_buffer_cursor)
    if _file.valid() then
      let read_bytes = _last_line_length.max(_min_read_size)
      let read_buf = _file.read(read_bytes)
      _buffer_cursor = _file.position()

      let errno = _file.errno()
      if (read_buf.size() == 0) and (errno isnt FileOK) then
        result = false
      else
        // TODO: Limit size of read buffer
        _reader.append(consume read_buf)
      end
    else
      result = false
    end
    // reset position to not disturb other operations on the file
    // we only actually advance the cursor if the line is returned.
    _file.seek_start(current_pos)
    result

  fun ref _read_last_line(): String iso^ ? =>
    let block = _reader.block(_reader.size())?
    _inc_public_file_cursor(block.size())
    String.from_iso_array(consume block)

  fun ref _inc_public_file_cursor(amount: USize) =>
    _cursor = _cursor + amount
    _file.seek_start(_cursor)