ReadlineManager.php

PHP

Path: src/CLI/ReadlineManager.php

<?php

namespace mini\CLI;

use Closure;
use RuntimeException;

/**
 * Wrapper around PHP's readline callback interface with proper signal handling
 *
 * Provides a clean way to handle Ctrl+C:
 * - Returns '' if cancelled with content in buffer (user wants to clear line)
 * - Returns null if cancelled with empty buffer (user wants to exit)
 *
 * ```php
 * $rl = new ReadlineManager('sql> ');
 * pcntl_signal(SIGINT, fn() => $rl->cancel());
 * pcntl_async_signals(true);
 *
 * while (($line = $rl->prompt()) !== null) {
 *     if ($line === '') continue; // Ctrl+C with content, or empty enter
 *     $rl->addHistory($line);
 *     processLine($line);
 * }
 * ```
 */
class ReadlineManager
{
    private string $prompt;
    private ?string $input = null;
    private bool $prompting = false;
    private bool $installed = false;
    private array $history = [];
    private ?Closure $completionFunction = null;

    public function __construct(string $prompt)
    {
        $this->prompt = $prompt;
    }

    public function __destruct()
    {
        // Ensure readline handler is removed on exit
        if ($this->installed && function_exists('readline_callback_handler_remove')) {
            @readline_callback_handler_remove();
        }
    }

    /**
     * Set the default prompt
     */
    public function setPrompt(string $prompt): void
    {
        $this->prompt = $prompt;
    }

    /**
     * Add entry to history
     */
    public function addHistory(string $entry): void
    {
        $this->history[] = $entry;
        if ($this->installed) {
            readline_add_history($entry);
        }
    }

    /**
     * Clear all history
     */
    public function clearHistory(): void
    {
        $this->history = [];
        if ($this->installed) {
            readline_clear_history();
        }
    }

    /**
     * Get current history entries
     */
    public function getHistory(): array
    {
        if ($this->installed) {
            return readline_list_history();
        }
        return $this->history;
    }

    /**
     * Load history from array
     */
    public function loadHistory(array $entries): void
    {
        foreach ($entries as $entry) {
            $this->history[] = $entry;
        }
    }

    /**
     * Set completion function
     */
    public function setCompletionFunction(Closure $fn): void
    {
        $this->completionFunction = $fn;
        if ($this->installed) {
            if (function_exists('readline_completion_function')) {
                readline_completion_function($fn);
            }
        }
    }

    /**
     * Prompt for input
     *
     * @param string|null $alternativePrompt One-time prompt override
     * @return string|null The input line, '' if cancelled with content, null if cancelled empty
     */
    public function prompt(?string $alternativePrompt = null): ?string
    {
        if ($this->installed) {
            throw new RuntimeException("ReadlineManager: prompt() called while already prompting");
        }

        if (!function_exists('readline_callback_handler_install')) {
            // Fallback for systems without readline callback support
            echo $alternativePrompt ?? $this->prompt;
            $line = fgets(STDIN);
            return $line === false ? null : rtrim($line, "\n\r");
        }

        $this->installed = true;

        // Register completion function BEFORE handler install
        if ($this->completionFunction !== null && function_exists('readline_completion_function')) {
            readline_completion_function($this->completionFunction);
        }

        readline_callback_handler_install($alternativePrompt ?? $this->prompt, $this->callback(...));

        // Load history into readline
        foreach ($this->history as $h) {
            readline_add_history($h);
        }

        $this->prompting = true;
        $readCount = $this->loop();
        $result = $this->input;
        $this->input = null;

        // Distinguish: cancelled with content vs cancelled empty
        if ($readCount > 0) {
            return $result ?? '';
        }
        return $result;
    }

    /**
     * Cancel current prompt (call from signal handler)
     */
    public function cancel(): void
    {
        $this->prompting = false;
    }

    /**
     * Check if currently prompting
     */
    public function isPrompting(): bool
    {
        return $this->prompting;
    }

    /**
     * Get current line buffer content
     */
    public function getLineBuffer(): string
    {
        return (string) readline_info('line_buffer');
    }

    private function loop(): int
    {
        $readCount = 0;
        while ($this->prompting) {
            $r = array(STDIN);
            $w = NULL;
            $e = NULL;
            $n = @stream_select($r, $w, $e, 1, 150000);
            if ($n && in_array(STDIN, $r)) {
                ++$readCount;
                readline_callback_read_char();
            }
        }
        if ($this->installed) {
            readline_callback_handler_remove();
            $this->installed = false;
        }
        return $readCount;
    }

    private function callback(?string $line): void
    {
        $this->input = $line;
        $this->prompting = false;
        $this->installed = false;
    }
}