readline.pony

use "collections"
use "files"
use "promises"
use strings = "strings"

class Readline is ANSINotify
  """
  Line editing, history, and tab completion.
  """
  let _notify: ReadlineNotify
  let _out: OutStream
  let _path: (FilePath | None)
  embed _history: Array[String]
  embed _queue: Array[String] = Array[String]
  let _maxlen: USize

  var _edit: String iso = recover String end
  var _cur_prompt: String = ""
  var _cur_line: USize = 0
  var _cur_pos: ISize = 0
  var _blocked: Bool = true

  new iso create(
    notify: ReadlineNotify iso,
    out: OutStream,
    path: (FilePath | None) = None,
    maxlen: USize = 0)
  =>
    """
    Create a readline handler to be passed to stdin. It begins blocked. Set an
    initial prompt on the ANSITerm to begin processing.
    """
    _notify = consume notify
    _out = out
    _path = path
    _history = Array[String](maxlen)
    _maxlen = maxlen

    _load_history()

  fun ref apply(term: ANSITerm ref, input: U8) =>
    """
    Receives input.
    """
    match input
    | 0x01 => home() // ctrl-a
    | 0x02 => left() // ctrl-b
    | 0x04 =>
      // ctrl-d
      if _edit.size() == 0 then
        _out.write("\n")
        term.dispose()
      else
        delete()
      end
    | 0x05 => end_key() // ctrl-e
    | 0x06 => right() // ctrl-f
    | 0x08 => _backspace() // ctrl-h
    | 0x09 => _tab()
    | 0x0A => _dispatch(term) // LF
    | 0x0B =>
      // ctrl-k, delete to the end of the line.
      _edit.truncate(_cur_pos.usize())
    | 0x0C => _clear() // ctrl-l
    | 0x0D => _dispatch(term) // CR
    | 0x0E => down() // ctrl-n
    | 0x10 => up() // ctrl-p
    | 0x14 => _swap() // ctrl-t
    | 0x15 =>
      // ctrl-u, delete the whole line.
      _edit.clear()
      home()
    | 0x17 => _delete_prev_word() // ctrl-w
    | 0x7F => _backspace() // backspace
    | if input < 0x20 => None // unknown control character
    else
      // Insert.
      _edit.insert_byte(_cur_pos, input)
      _cur_pos = _cur_pos + 1
      _refresh_line()
    end

  fun ref prompt(term: ANSITerm ref, value: String) =>
    """
    Set a new prompt, unblock, and handle the pending queue.
    """
    _cur_prompt = value
    _blocked = false

    try
      let line = _queue.shift()?
      _add_history(line)
      _out.print(_cur_prompt + line)
      _handle_line(term, line)
    else
      _refresh_line()
    end

  fun ref closed() =>
    """
    No more input is available.
    """
    _save_history()

  fun ref up(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Previous line.
    """
    try
      if _cur_line > 0 then
        _cur_line = _cur_line - 1
        _edit = _history(_cur_line)?.clone()
        end_key()
      end
    end

  fun ref down(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Next line.
    """
    try
      if _cur_line < (_history.size() - 1) then
        _cur_line = _cur_line + 1
        _edit = _history(_cur_line)?.clone()
      else
        _cur_line = _history.size()
        _edit.clear()
      end

      end_key()
    end

  fun ref left(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Move left.
    """
    if _cur_pos == 0 then
      return
    end

    try
      repeat
        _cur_pos = _cur_pos - 1
      until
        (_cur_pos == 0) or
        ((_edit.at_offset(_cur_pos)? and 0xC0) != 0x80)
      end

      _refresh_line()
    end

  fun ref right(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Move right.
    """
    try
      if _cur_pos < _edit.size().isize() then
        _cur_pos = _cur_pos + 1
      end

      while
        (_cur_pos < _edit.size().isize()) and
        ((_edit.at_offset(_cur_pos)? and 0xC0) == 0x80)
      do
        _cur_pos = _cur_pos + 1
      end

      _refresh_line()
    end

  fun ref home(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Beginning of the line.
    """
    _cur_pos = 0
    _refresh_line()

  fun ref end_key(
    ctrl: Bool = false,
    alt: Bool = false,
    shift: Bool = false)
  =>
    """
    End of the line.
    """
    _cur_pos = _edit.size().isize()
    _refresh_line()

  fun ref _backspace() =>
    """
    Backward delete.
    """
    if _cur_pos == 0 then
      return
    end

    try
      var c = U8(0)

      repeat
        _cur_pos = _cur_pos - 1
        c = _edit.at_offset(_cur_pos)?
        _edit.delete(_cur_pos, 1)
      until
        (_cur_pos == 0) or ((c and 0xC0) != 0x80)
      end

      _refresh_line()
    end

  fun ref delete(ctrl: Bool = false, alt: Bool = false, shift: Bool = false) =>
    """
    Forward delete.
    """
    try
      if _cur_pos < _edit.size().isize() then
        _edit.delete(_cur_pos, 1)
      end

      while
        (_cur_pos < _edit.size().isize()) and
        ((_edit.at_offset(_cur_pos)? and 0xC0) == 0x80)
      do
        _edit.delete(_cur_pos, 1)
      end

      _refresh_line()
    end

  fun ref _clear() =>
    """
    Clear the screen.
    """
    _out.write(ANSI.clear())
    _refresh_line()

  fun ref _swap() =>
    """
    Swap the previous character with the current one.
    """
    try
      if (_cur_pos > 0) and (_cur_pos < _edit.size().isize()) then
        _edit(_cur_pos.usize())? =
          _edit((_cur_pos - 1).usize())? =
            _edit(_cur_pos.usize())?
      end

      _refresh_line()
    end

  fun ref _delete_prev_word() =>
    """
    Delete the previous word.
    """
    try
      let old = _cur_pos

      while (_cur_pos > 0) and (_edit((_cur_pos - 1).usize())? == ' ') do
        _cur_pos = _cur_pos - 1
      end

      while (_cur_pos > 0) and (_edit((_cur_pos - 1).usize())? != ' ') do
        _cur_pos = _cur_pos - 1
      end

      _edit.delete(_cur_pos, (old - _cur_pos).usize())
      _refresh_line()
    end

  fun ref _tab() =>
    """
    Tab completion.

    TODO: Improve this.
    """
    let r = _notify.tab(_edit.clone())

    match r.size()
    | 0 => None
    | 1 =>
      try
        _edit = r(0)?.clone()
        end_key()
      end
    else
      _out.write("\n")

      for completion in r.values() do
        _out.print(completion)
      end

      _edit = strings.CommonPrefix(r)
      end_key()
    end

  fun ref _dispatch(term: ANSITerm) =>
    """
    Send a finished line to the notifier.
    """
    if _edit.size() > 0 then
      let line: String = _edit = recover String end

      if _blocked then
        _queue.push(line)
      else
        _add_history(line)
        _out.write("\n")
        _handle_line(term, line)
      end
    end

  fun ref _handle_line(term: ANSITerm, line: String) =>
    """
    Dispatch a single line.
    """
    let promise = Promise[String]

    promise.next[Any tag](
      recover term~prompt() end,
      recover term~dispose() end)

    _notify(line, promise)
    _cur_pos = 0
    _blocked = true

  fun ref _refresh_line() =>
    """
    Refresh the line on screen.
    """
    if not _blocked then
      let len = 40 + _cur_prompt.size() + _edit.size()
      let out = recover String(len) end

      // Move to the left edge.
      out.append("\r")

      // Print the prompt.
      out.append(_cur_prompt)

      // Print the current line.
      out.append(_edit.clone())

      // Erase to the right edge.
      out.append(ANSI.erase())

      // Set the cursor position.
      var pos = _cur_prompt.codepoints()

      if _cur_pos > 0 then
        pos = pos + _edit.codepoints(0, _cur_pos)
      end

      out.append("\r")
      out.append(ANSI.right(pos.u32()))
      _out.write(consume out)
    end

  fun ref _add_history(line: String) =>
    """
    Add a line to the history, trimming an earlier line if necessary.
    """
    try
      if (_history.size() > 0) and (_history(_history.size() - 1)? == line) then
        _cur_line = _history.size()
        return
      end
    end

    if (_maxlen > 0) and (_history.size() >= _maxlen) then
      try
        _history.shift()?
      end
    end

    _history.push(line)
    _cur_line = _history.size()

  fun ref _load_history() =>
    """
    Load the history from a file.
    """
    _history.clear()

    try
      with file = File.open(_path as FilePath) do
        for line in file.lines() do
          _add_history(consume line)
        end
      end
    end

  fun _save_history() =>
    """
    Write the history back to a file.
    """
    try
      with file = File(_path as FilePath) do
        for line in _history.values() do
          file.print(line)
        end
      end
    end