Skip to content

linenoise-go/linenoise.go

:material-github: View on GitHub · 671 lines · live source, included at build time

// Package linenoise provides a minimal line editing library for CLI applications.
//
// This is a Go port of antirez/linenoise (https://github.com/antirez/linenoise),
// a minimal alternative to readline and libedit.
//
// Features:
//   - Single and multi-line editing
//   - Command history with persistence
//   - Tab completion support
//   - Inline hints
//   - Password masking
//   - Cross-platform (Unix/Linux/macOS)
//
// Basic usage:
//
//  line, err := linenoise.Line("prompt> ")
//  if err != nil {
//      if err == io.EOF {
//          // User pressed Ctrl+D
//          return
//      }
//      log.Fatal(err)
//  }
//  linenoise.AddHistory(line)
package linenoise

import (
    "bufio"
    "errors"
    "fmt"
    "io"
    "os"
    "strings"
    "sync"
    "unicode"
    "unicode/utf8"

    "golang.org/x/term"
)

// Version information.
const (
    Version = "0.1.0"
)

// Common errors.
var (
    ErrNotTTY      = errors.New("not a terminal")
    ErrUnsupported = errors.New("unsupported terminal")
    ErrInterrupted = errors.New("interrupted")
    ErrInvalidUTF8 = errors.New("invalid UTF-8 sequence")
)

// CompletionCallback is called when the user presses Tab.
// It should populate completions based on the current line buffer.
type CompletionCallback func(line string) []string

// HintsCallback is called to provide inline hints as the user types.
// It returns the hint text and whether to display it in bold.
type HintsCallback func(line string) (hint string, bold bool)

// Config holds linenoise configuration options.
type Config struct {
    // HistoryMaxLen is the maximum number of history entries (0 = unlimited)
    HistoryMaxLen int

    // MultiLine enables multi-line editing mode
    MultiLine bool

    // MaskMode enables password masking (characters shown as '*')
    MaskMode bool

    // CompletionCallback is called for tab completion
    CompletionCallback CompletionCallback

    // HintsCallback is called to display inline hints
    HintsCallback HintsCallback

    // Input/Output streams (defaults to os.Stdin/os.Stdout)
    Input  *os.File
    Output *os.File
}

// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() *Config {
    return &Config{
        HistoryMaxLen: 100,
        MultiLine:     false,
        MaskMode:      false,
        Input:         os.Stdin,
        Output:        os.Stdout,
    }
}

// State represents the current line editing state.
type State struct {
    // mu guards history (and historyIndex/draftLine) so the package-level
    // convenience functions, which all share one defaultState, are safe to call
    // from multiple goroutines. A per-goroutine State needs no external locking.
    mu      sync.Mutex
    config  *Config
    history []string
    buf     []rune
    pos     int
    prompt  string
    oldTerm *term.State

    // Completion state
    completions      []string
    completionIndex  int
    completionActive bool
    savedBuf         []rune

    // History navigation state
    historyIndex int    // cursor into history during Up/Down navigation
    draftLine    []rune // the live line stashed when navigation begins
}

// New creates a new linenoise state with the given configuration.
func New(config *Config) *State {
    if config == nil {
        config = DefaultConfig()
    }
    return &State{
        config:  config,
        history: make([]string, 0, config.HistoryMaxLen),
        buf:     make([]rune, 0, 128),
    }
}

// Line reads a line of input with the given prompt.
// This is a convenience function that creates a new State and calls ReadLine.
func Line(prompt string) (string, error) {
    state := New(DefaultConfig())
    return state.ReadLine(prompt)
}

// ReadLine reads a line of input with the given prompt.
func (s *State) ReadLine(prompt string) (string, error) {
    // Check if we're connected to a terminal
    if !term.IsTerminal(int(s.config.Input.Fd())) {
        return s.readLineNoTTY()
    }

    s.prompt = prompt
    s.buf = s.buf[:0]
    s.pos = 0
    s.completionActive = false
    s.mu.Lock()
    s.historyIndex = len(s.history)
    s.mu.Unlock()
    s.draftLine = nil

    // Enable raw mode
    oldState, err := term.MakeRaw(int(s.config.Input.Fd()))
    if err != nil {
        return "", fmt.Errorf("enable raw mode: %w", err)
    }
    s.oldTerm = oldState
    defer s.restore()

    // Display prompt
    fmt.Fprint(s.config.Output, prompt)

    // Read and process input
    reader := bufio.NewReader(s.config.Input)
    for {
        ch, err := s.readChar(reader)
        if err != nil {
            if err == io.EOF {
                if len(s.buf) > 0 {
                    fmt.Fprintln(s.config.Output)
                    return string(s.buf), nil
                }
                return "", io.EOF
            }
            return "", err
        }

        cont, result, err := s.processChar(ch)
        if !cont {
            if err != nil {
                return "", err
            }
            fmt.Fprintln(s.config.Output)
            return result, nil
        }
    }
}

// readChar reads a single character or escape sequence.
func (s *State) readChar(reader *bufio.Reader) (rune, error) {
    b, err := reader.ReadByte()
    if err != nil {
        // Pass EOF through unwrapped so callers can detect it with == io.EOF.
        if errors.Is(err, io.EOF) {
            return 0, io.EOF
        }
        return 0, fmt.Errorf("read input: %w", err)
    }

    // Check for escape sequences
    if b == '\x1b' {
        // Try to read escape sequence
        b2, err := reader.ReadByte()
        if err != nil {
            return '\x1b', nil
        }

        if b2 == '[' || b2 == 'O' {
            b3, err := reader.ReadByte()
            if err != nil {
                return '\x1b', nil
            }

            // Arrow keys and other special keys
            switch b3 {
            case 'A':
                return keyUp, nil
            case 'B':
                return keyDown, nil
            case 'C':
                return keyRight, nil
            case 'D':
                return keyLeft, nil
            case 'H':
                return keyHome, nil
            case 'F':
                return keyEnd, nil
            case '1', '7': // Home
                _, _ = reader.ReadByte() // consume ~
                return keyHome, nil
            case '3': // Delete
                _, _ = reader.ReadByte() // consume ~
                return keyDelete, nil
            case '4', '8': // End
                _, _ = reader.ReadByte() // consume ~
                return keyEnd, nil
            }
        }
        return '\x1b', nil
    }

    // Handle UTF-8
    if b < 128 {
        return rune(b), nil
    }

    // Multi-byte UTF-8 character
    _ = reader.UnreadByte()
    r, _, err := reader.ReadRune()
    if err != nil {
        return r, fmt.Errorf("read rune: %w", err)
    }
    return r, nil
}

// Special key codes.
const (
    keyUp rune = 0x10000 + iota
    keyDown
    keyLeft
    keyRight
    keyHome
    keyEnd
    keyDelete
)

// processChar processes a single character and returns:
//   - cont: whether to continue reading
//   - result: the final line (if cont is false)
//   - err: any error that occurred.
func (s *State) processChar(ch rune) (cont bool, result string, err error) {
    switch ch {
    case '\r', '\n': // Enter
        return false, string(s.buf), nil

    case '\x03': // Ctrl+C
        return false, "", ErrInterrupted

    case '\x04': // Ctrl+D (EOF on empty line, else delete under cursor)
        if len(s.buf) == 0 {
            return false, "", io.EOF
        }
        s.deleteChar()

    default:
        s.handleEditKey(ch)
    }

    s.refresh()
    return true, "", nil
}

// handleEditKey applies a cursor-movement or editing key to the line buffer.
// Equivalent control characters and escape-sequence key codes share a case.
func (s *State) handleEditKey(ch rune) {
    // Any key other than Tab exits completion-cycling mode, so a later Tab
    // recomputes completions from the (possibly edited) buffer instead of
    // overwriting the user's edits with a stale completion.
    if ch != '\t' {
        s.completionActive = false
    }
    switch ch {
    case '\x08', '\x7f': // Backspace
        s.backspace()

    case '\t': // Tab (completion)
        s.handleCompletion()

    case '\x01', keyHome: // Ctrl+A / Home
        s.pos = 0

    case '\x05', keyEnd: // Ctrl+E / End
        s.pos = len(s.buf)

    case '\x02', keyLeft: // Ctrl+B / Left
        if s.pos > 0 {
            s.pos--
        }

    case '\x06', keyRight: // Ctrl+F / Right
        if s.pos < len(s.buf) {
            s.pos++
        }

    case '\x0b': // Ctrl+K (delete to end of line)
        s.buf = s.buf[:s.pos]

    case '\x15': // Ctrl+U (delete entire line)
        s.buf = s.buf[:0]
        s.pos = 0

    case '\x17': // Ctrl+W (delete previous word)
        s.deletePrevWord()

    case '\x0c': // Ctrl+L (clear screen)
        fmt.Fprint(s.config.Output, "\x1b[H\x1b[2J")

    case keyUp:
        s.historyPrev()

    case keyDown:
        s.historyNext()

    case keyDelete:
        s.deleteChar()

    default:
        // Regular character - insert at cursor position.
        if unicode.IsPrint(ch) || ch == ' ' {
            s.insertChar(ch)
        }
    }
}

// insertChar inserts a character at the cursor position.
func (s *State) insertChar(ch rune) {
    // Expand buffer if needed
    if s.pos == len(s.buf) {
        s.buf = append(s.buf, ch)
        s.pos++
        return
    }

    // Insert in middle
    s.buf = append(s.buf, 0)
    copy(s.buf[s.pos+1:], s.buf[s.pos:])
    s.buf[s.pos] = ch
    s.pos++
}

// deleteChar deletes the character under the cursor.
func (s *State) deleteChar() {
    if s.pos < len(s.buf) {
        copy(s.buf[s.pos:], s.buf[s.pos+1:])
        s.buf = s.buf[:len(s.buf)-1]
    }
}

// backspace deletes the character before the cursor.
func (s *State) backspace() {
    if s.pos > 0 {
        s.pos--
        s.deleteChar()
    }
}

// deletePrevWord deletes the previous word (Ctrl+W).
func (s *State) deletePrevWord() {
    if s.pos == 0 {
        return
    }

    // Find start of word
    oldPos := s.pos
    for s.pos > 0 && unicode.IsSpace(s.buf[s.pos-1]) {
        s.pos--
    }
    for s.pos > 0 && !unicode.IsSpace(s.buf[s.pos-1]) {
        s.pos--
    }

    // Delete from pos to oldPos
    copy(s.buf[s.pos:], s.buf[oldPos:])
    s.buf = s.buf[:len(s.buf)-(oldPos-s.pos)]
}

// refresh redraws the line.
func (s *State) refresh() {
    // Move cursor to start of line
    fmt.Fprintf(s.config.Output, "\r\x1b[K")

    // Print prompt
    fmt.Fprint(s.config.Output, s.prompt)

    // Print buffer (with masking if enabled)
    if s.config.MaskMode {
        for range s.buf {
            fmt.Fprint(s.config.Output, "*")
        }
    } else {
        fmt.Fprint(s.config.Output, string(s.buf))
    }

    // Print hint if available
    if s.config.HintsCallback != nil && !s.config.MaskMode {
        hint, bold := s.config.HintsCallback(string(s.buf))
        if hint != "" {
            if bold {
                fmt.Fprintf(s.config.Output, "\x1b[1;90m%s\x1b[0m", hint)
            } else {
                fmt.Fprintf(s.config.Output, "\x1b[90m%s\x1b[0m", hint)
            }
        }
    }

    // Move cursor to correct position. \r homes the cursor; only emit CUF when
    // there is a positive column to move to (ESC[0C is treated as ESC[1C by most
    // terminals, an off-by-one on the empty-prompt/pos-0 edge).
    promptLen := utf8.RuneCountInString(s.prompt)
    targetCol := promptLen + s.pos
    if targetCol > 0 {
        fmt.Fprintf(s.config.Output, "\r\x1b[%dC", targetCol)
    } else {
        fmt.Fprint(s.config.Output, "\r")
    }
}

// handleCompletion handles tab completion.
func (s *State) handleCompletion() {
    if s.config.CompletionCallback == nil {
        return
    }

    // First tab - get completions
    if !s.completionActive {
        s.completions = s.config.CompletionCallback(string(s.buf))
        if len(s.completions) == 0 {
            return
        }
        s.completionActive = true
        s.completionIndex = 0
        s.savedBuf = append([]rune(nil), s.buf...)
    } else {
        // Cycle to next completion
        s.completionIndex = (s.completionIndex + 1) % len(s.completions)
    }

    // Replace buffer with completion
    s.buf = []rune(s.completions[s.completionIndex])
    s.pos = len(s.buf)
}

// historyPrev recalls the previous (older) history entry into the line buffer.
// The first move stashes the in-progress line so historyNext can restore it.
func (s *State) historyPrev() {
    s.mu.Lock()
    defer s.mu.Unlock()
    if len(s.history) == 0 {
        return
    }
    if s.historyIndex == len(s.history) {
        s.draftLine = append([]rune(nil), s.buf...)
    }
    if s.historyIndex == 0 {
        return // already at the oldest entry
    }
    s.historyIndex--
    s.buf = []rune(s.history[s.historyIndex])
    s.pos = len(s.buf)
}

// historyNext moves toward newer history entries, restoring the stashed draft
// line once it moves past the most recent entry.
func (s *State) historyNext() {
    s.mu.Lock()
    defer s.mu.Unlock()
    if len(s.history) == 0 || s.historyIndex >= len(s.history) {
        return
    }
    s.historyIndex++
    if s.historyIndex == len(s.history) {
        s.buf = append([]rune(nil), s.draftLine...)
    } else {
        s.buf = []rune(s.history[s.historyIndex])
    }
    s.pos = len(s.buf)
}

// restore restores terminal to normal mode.
func (s *State) restore() {
    if s.oldTerm != nil {
        _ = term.Restore(int(s.config.Input.Fd()), s.oldTerm)
        s.oldTerm = nil
    }
}

// readLineNoTTY reads a line when not connected to a terminal.
func (s *State) readLineNoTTY() (string, error) {
    reader := bufio.NewReader(s.config.Input)
    line, err := reader.ReadString('\n')
    if err != nil {
        // A final line without a trailing newline arrives together with io.EOF;
        // return its content rather than discarding it.
        if errors.Is(err, io.EOF) {
            line = strings.TrimSuffix(line, "\n")
            if line == "" {
                return "", io.EOF
            }
            return line, nil
        }
        return "", fmt.Errorf("read line: %w", err)
    }
    return strings.TrimSuffix(line, "\n"), nil
}

// AddHistory adds a line to the history.
func (s *State) AddHistory(line string) {
    line = strings.TrimSpace(line)
    if line == "" {
        return
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    // Don't add duplicates
    if len(s.history) > 0 && s.history[len(s.history)-1] == line {
        return
    }

    // Add to history
    s.history = append(s.history, line)

    // Trim if exceeds max length
    if s.config.HistoryMaxLen > 0 && len(s.history) > s.config.HistoryMaxLen {
        s.history = s.history[len(s.history)-s.config.HistoryMaxLen:]
    }
}

// SaveHistory saves the history to a file.
func (s *State) SaveHistory(filename string) error {
    // Snapshot under lock, then do file I/O without holding it.
    s.mu.Lock()
    snapshot := append([]string(nil), s.history...)
    s.mu.Unlock()

    f, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("create history file: %w", err)
    }
    defer f.Close()

    for _, line := range snapshot {
        if _, err := fmt.Fprintln(f, line); err != nil {
            return fmt.Errorf("write history: %w", err)
        }
    }
    return nil
}

// LoadHistory loads history from a file. It reads into a temporary slice and
// swaps it in only on success, so a read error (including a line exceeding any
// scanner limit) does not destroy the existing in-memory history.
func (s *State) LoadHistory(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return nil // Not an error if file doesn't exist
        }
        return fmt.Errorf("open history file: %w", err)
    }
    defer f.Close()

    // bufio.Reader (not Scanner) so arbitrarily long lines don't trip the 64KB
    // token cap and abort the load.
    var loaded []string
    reader := bufio.NewReader(f)
    for {
        line, readErr := reader.ReadString('\n')
        if trimmed := strings.TrimRight(line, "\r\n"); trimmed != "" {
            loaded = append(loaded, trimmed)
        }
        if readErr != nil {
            if errors.Is(readErr, io.EOF) {
                break
            }
            return fmt.Errorf("read history: %w", readErr)
        }
    }

    s.mu.Lock()
    defer s.mu.Unlock()
    s.history = loaded
    if s.config.HistoryMaxLen > 0 && len(s.history) > s.config.HistoryMaxLen {
        s.history = s.history[len(s.history)-s.config.HistoryMaxLen:]
    }
    return nil
}

// ClearHistory clears all history entries.
func (s *State) ClearHistory() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.history = s.history[:0]
}

// Global convenience functions for simple usage

var defaultState = New(DefaultConfig())

// AddHistory adds a line to the global history.
func AddHistory(line string) {
    defaultState.AddHistory(line)
}

// SaveHistory saves the global history to a file.
func SaveHistory(filename string) error {
    return defaultState.SaveHistory(filename)
}

// LoadHistory loads the global history from a file.
func LoadHistory(filename string) error {
    return defaultState.LoadHistory(filename)
}

// ClearHistory clears the global history.
func ClearHistory() {
    defaultState.ClearHistory()
}

// SetCompletionCallback sets the global completion callback.
func SetCompletionCallback(cb CompletionCallback) {
    defaultState.config.CompletionCallback = cb
}

// SetHintsCallback sets the global hints callback.
func SetHintsCallback(cb HintsCallback) {
    defaultState.config.HintsCallback = cb
}

// SetMultiLine enables or disables multi-line mode globally.
func SetMultiLine(enabled bool) {
    defaultState.config.MultiLine = enabled
}

// SetMaskMode enables or disables password masking globally.
func SetMaskMode(enabled bool) {
    defaultState.config.MaskMode = enabled
}