update lock clucknut
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 14s
Build, Push and Deploy / build-and-push (push) Successful in 3m14s
Build, Push and Deploy / deploy-staging (push) Successful in 25s
Build, Push and Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-18 20:32:18 +07:00
parent 4554035227
commit dcaf267458
3359 changed files with 153185 additions and 205489 deletions

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Accept the entire current suggestion.
*/
class AcceptSuggestionAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$suggestion = $readline->getCurrentSuggestion();
if ($suggestion === null) {
return false;
}
$text = $suggestion->applyToBuffer($buffer->getText());
$buffer->setText($text);
$buffer->setCursor($suggestion->getReplaceStart() + \mb_strlen($suggestion->getAcceptText()));
$readline->clearSuggestion();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'accept-suggestion';
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\WordNavigationPolicy;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Accept one word from the current suggestion.
*/
class AcceptSuggestionWordAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$suggestion = $readline->getCurrentSuggestion();
if ($suggestion === null) {
return false;
}
// Word-accept currently supports append-only suggestions.
if (!$suggestion->isAppendOnly($buffer->getCursor())) {
return false;
}
$text = $suggestion->getAcceptText();
$wordEnd = (new WordNavigationPolicy())->findNextWord($text, 0);
$firstWord = \mb_substr($text, 0, $wordEnd);
if ($firstWord === '') {
return false;
}
$buffer->insert($firstWord);
$readline->clearSuggestion();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'accept-suggestion-word';
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Action interface.
*
* Represents an action that can be executed in response to user input.
*/
interface ActionInterface
{
/**
* Execute the action.
*
* @return bool True if the readline loop should continue, false to break
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool;
/**
* Get the action name.
*
* @return string
*/
public function getName(): string;
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Clear the current input buffer (Ctrl-C).
*
* - In single-line mode, clears the current line.
* - In multi-line mode, exits multi-line and clears entire buffer.
* - On empty line, beeps (or could show hint: "Use Ctrl-D to exit").
*/
class ClearBufferAction implements ActionInterface
{
private static int $lastEmptyCtrlCTime = 0;
private const HINT_TIMEOUT = 2; // seconds
/**
* Reset the Ctrl-C timer (for testing).
*/
public static function resetTimer(): void
{
self::$lastEmptyCtrlCTime = 0;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($readline->isMultilineMode()) {
$buffer->clear();
return true;
}
if ($buffer->isEmpty()) {
$now = \time();
$timeSinceLastCtrlC = $now - self::$lastEmptyCtrlCTime;
// If Ctrl-C pressed twice within timeout, show exit hint
if (self::$lastEmptyCtrlCTime > 0 && $timeSinceLastCtrlC <= self::HINT_TIMEOUT) {
$terminal->write("\r");
$terminal->clearToEndOfLine();
$terminal->writeFormatted('<whisper>(Press Ctrl-D to exit, or type \'exit\')</whisper>');
$terminal->write("\n");
self::$lastEmptyCtrlCTime = 0; // Reset to avoid showing hint repeatedly
} else {
$terminal->bell();
self::$lastEmptyCtrlCTime = $now;
}
return true;
}
$buffer->clear();
self::$lastEmptyCtrlCTime = 0;
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'clear-buffer';
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Clear the screen, keeping the current input buffer (Ctrl-L).
*/
class ClearScreenAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$readline->clearPreviousLines();
// Move cursor to top-left and clear entire screen
$terminal->write("\033[H\033[2J");
$terminal->invalidateFrame(true);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'clear-screen';
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Dedent leading indentation to the previous tab stop.
*
* Returns false when the cursor is not in leading indentation.
*/
class DedentLeadingIndentationAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!$readline->isMultilineMode()) {
return false;
}
$line = $buffer->getCurrentLineText();
$indentLength = \strspn($line, " \t");
if ($indentLength === 0) {
return false;
}
$cursorInLine = $buffer->getCursorPositionInLine();
if ($cursorInLine > $indentLength) {
return false;
}
// Cursor is at line start: forward-delete leading indentation.
if ($cursorInLine === 0) {
if ($line[0] === "\t") {
return $buffer->deleteForward(1);
}
$leadingSpaces = \strspn($line, ' ');
return $buffer->deleteForward($buffer->spacesToPreviousTabStop($leadingSpaces));
}
// Cursor is within leading whitespace: backward-delete to previous tab stop.
if ($line[$cursorInLine - 1] === "\t") {
return $buffer->deleteBackward(1);
}
$beforeCursor = \substr($line, 0, $cursorInLine);
$trailingSpaces = \strlen($beforeCursor) - \strlen(\rtrim($beforeCursor, ' '));
return $buffer->deleteBackward($buffer->spacesToPreviousTabStop($trailingSpaces));
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'dedent-leading-indentation';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Delete one character before the cursor.
*/
class DeleteBackwardCharAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deleteBackward();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'delete-backward-char';
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Helper\BracketPair;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Delete matching bracket pairs when the cursor is between them.
*/
class DeleteBracketPairAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!BracketPair::shouldDeletePair($buffer)) {
return false;
}
$buffer->deleteBackward(1);
$buffer->deleteForward(1);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'delete-bracket-pair';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Delete the character at the cursor (Delete key).
*/
class DeleteForwardAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deleteForward();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'delete-forward';
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Exception\BreakException;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Exit the session when the current buffer is empty.
*/
class ExitIfEmptyAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!$buffer->isEmpty()) {
return false;
}
$readline->escapeCurrentFrameForAbort($buffer);
throw new BreakException('Ctrl+D');
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'exit-if-empty';
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Expand history shorthand (e.g. !!, !$) on Tab when present.
*/
class ExpandHistoryOnTabAction implements ActionInterface
{
private HistoryExpansionAction $historyExpansion;
public function __construct(HistoryExpansionAction $historyExpansion)
{
$this->historyExpansion = $historyExpansion;
}
/**
* Replace the HistoryExpansionAction (e.g. after Shell is set).
*/
public function setHistoryExpansion(HistoryExpansionAction $historyExpansion): void
{
$this->historyExpansion = $historyExpansion;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($this->historyExpansion->detectExpansion($buffer->getText(), $buffer->getCursor()) === null) {
return false;
}
return $this->historyExpansion->execute($buffer, $terminal, $readline);
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'expand-history-on-tab';
}
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Action chain with fallback behavior.
*
* Executes actions in order until one handles the keypress.
*
* In a fallback chain, an action returning false means "not handled, try next
* fallback action". If all actions return false, the chain returns the
* configured default result (continue by default).
*/
class FallbackAction implements ActionInterface
{
/** @var ActionInterface[] */
private array $actions;
private bool $defaultToContinue;
/**
* @param ActionInterface[] $actions
* @param bool $defaultToContinue Result when all actions return false
*/
public function __construct(array $actions, bool $defaultToContinue = true)
{
if (empty($actions)) {
throw new \InvalidArgumentException('FallbackAction requires at least one action.');
}
$this->actions = \array_values($actions);
$this->defaultToContinue = $defaultToContinue;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
foreach ($this->actions as $action) {
if ($action->execute($buffer, $terminal, $readline)) {
return true;
}
}
return $this->defaultToContinue;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'fallback('.\implode(',', \array_map(fn (ActionInterface $action) => $action->getName(), $this->actions)).')';
}
}

View File

@@ -0,0 +1,366 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use PhpParser\NodeTraverser;
use PhpParser\PrettyPrinter\Standard as Printer;
use Psy\Input\CodeArgument;
use Psy\Input\ShellInput;
use Psy\ParserFactory;
use Psy\Readline\Interactive\Helper\ArgumentExtractorVisitor;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
use Psy\Shell;
/**
* History expansion action.
*
* Expands history patterns when Tab is pressed:
* - !! = previous command
* - !$ = last argument of previous command
* - !^ = first argument of previous command
* - !* = all arguments of previous command
*/
class HistoryExpansionAction implements ActionInterface
{
private History $history;
private ?Shell $shell;
public function __construct(History $history, ?Shell $shell = null)
{
$this->history = $history;
$this->shell = $shell;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$text = $buffer->getText();
$cursor = $buffer->getCursor();
$expansion = $this->detectExpansion($text, $cursor);
if ($expansion === null) {
return true;
}
$replacement = $this->getExpansion($expansion['pattern']);
if ($replacement === null) {
$terminal->bell();
return true;
}
$before = \mb_substr($text, 0, $expansion['start']);
$after = \mb_substr($text, $expansion['end']);
$newText = $before.$replacement.$after;
$buffer->setText($newText);
$buffer->setCursor($expansion['start'] + \mb_strlen($replacement));
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'history-expansion';
}
/**
* Detect expansion pattern at or before cursor.
*
* @return array|null Array with 'pattern', 'start', 'end' or null
*/
public function detectExpansion(string $text, int $cursor): ?array
{
$patterns = ['!!', '!*', '!^', '!$'];
foreach ($patterns as $pattern) {
$len = \mb_strlen($pattern);
$start = $cursor - $len;
if ($start < 0) {
continue;
}
$candidate = \mb_substr($text, $start, $len);
if ($candidate === $pattern) {
// Ensure pattern is at a word boundary
if ($start > 0) {
$prevChar = \mb_substr($text, $start - 1, 1);
if (\preg_match('/[\w$]/u', $prevChar)) {
continue;
}
}
return [
'pattern' => $pattern,
'start' => $start,
'end' => $cursor,
];
}
}
return null;
}
/**
* Get expansion for a pattern.
*
* @return string|null Expansion text or null if not available
*/
private function getExpansion(string $pattern): ?string
{
$entries = $this->history->getAll();
if (empty($entries)) {
return null;
}
$lastCommand = $entries[\count($entries) - 1]['command'];
switch ($pattern) {
case '!!':
return $lastCommand;
case '!$':
return $this->getLastArgument($lastCommand);
case '!^':
return $this->getFirstArgument($lastCommand);
case '!*':
return $this->getAllArguments($lastCommand);
default:
return null;
}
}
/**
* Get the first argument (index 0 is the command itself).
*/
private function getFirstArgument(string $command): ?string
{
return $this->parseArguments($command)[1] ?? null;
}
/**
* Get the last argument.
*/
private function getLastArgument(string $command): ?string
{
$args = $this->parseArguments($command);
if (empty($args)) {
return null;
}
return \end($args);
}
/**
* Get all arguments (excluding the command) joined with spaces.
*/
private function getAllArguments(string $command): ?string
{
$args = \array_slice($this->parseArguments($command), 1);
return empty($args) ? null : \implode(' ', $args);
}
/**
* Parse a command string into arguments.
*
* Handles both PsySH commands (like `ls $var`, `show Class::method`) and
* PHP code (like `echo $foo, $bar`).
*
* @return string[] Array of arguments
*/
private function parseArguments(string $command): array
{
$command = \trim($command);
if ($command === '') {
return [];
}
$psyshArgs = $this->parsePsyshCommand($command);
if ($psyshArgs !== null) {
return $psyshArgs;
}
return $this->parsePhpCode($command);
}
/**
* Parse PsySH command to extract code arguments.
*
* Returns null if the command is not a PsySH command with a CodeArgument.
*
* Examples:
* - `ls $var` ['ls', '$var']
* - `ls -al $foo->bar()` ['ls', '$foo->bar()']
* - `show --all SomeClass` ['show', 'SomeClass']
*
* @return string[]|null Array with command and code argument, or null if not a PsySH command
*/
private function parsePsyshCommand(string $command): ?array
{
if ($this->shell === null) {
return null;
}
if (!\preg_match('/^([^\s]+)/', $command, $match)) {
return null;
}
$commandName = $match[1];
try {
$cmd = $this->shell->get($commandName);
} catch (\Throwable $e) {
return null;
}
$definition = $cmd->getDefinition();
// Only handle commands that accept a CodeArgument
$hasCodeArg = false;
foreach ($definition->getArguments() as $arg) {
if ($arg instanceof CodeArgument) {
$hasCodeArg = true;
break;
}
}
if (!$hasCodeArg) {
return null;
}
$remainder = \ltrim(\substr($command, \strlen($commandName)));
if ($remainder === '') {
return [$commandName];
}
try {
$input = new ShellInput($remainder);
$input->bind($definition);
foreach ($definition->getArguments() as $arg) {
if ($arg instanceof CodeArgument) {
$codeArg = $input->getArgument($arg->getName());
if ($codeArg !== null && $codeArg !== '') {
return [$commandName, $codeArg];
}
break;
}
}
return [$commandName];
} catch (\Throwable $e) {
return $this->parseArgumentsFallback($command);
}
}
/**
* Parse PHP code to extract function/method call arguments.
*
* Uses PHP Parser to extract arguments from the AST.
*
* @return string[] Array of arguments
*/
private function parsePhpCode(string $code): array
{
try {
$parser = (new ParserFactory())->createParser();
$printer = new Printer();
$phpCode = '<?php '.$code;
// Suppress warnings so the parser throws on invalid code
// rather than recovering with a partial (incorrect) AST.
$stmts = @$parser->parse($phpCode.';');
if ($stmts === null) {
$stmts = @$parser->parse($phpCode);
}
if ($stmts === null || empty($stmts)) {
return $this->parseArgumentsFallback($code);
}
$visitor = new ArgumentExtractorVisitor($printer);
$traverser = new NodeTraverser();
$traverser->addVisitor($visitor);
$traverser->traverse($stmts);
$result = $visitor->getArguments();
// Fall back for things like "ls -al" that parse as PHP but aren't function calls
if (empty($result)) {
return $this->parseArgumentsFallback($code);
}
return $result;
} catch (\Throwable $e) {
return $this->parseArgumentsFallback($code);
}
}
/**
* Fallback argument parser that splits on whitespace while preserving quotes.
*
* @return string[] Array of arguments
*/
private function parseArgumentsFallback(string $command): array
{
$args = [];
$current = '';
$inQuote = null;
$len = \strlen($command);
for ($i = 0; $i < $len; $i++) {
$char = $command[$i];
if ($inQuote !== null) {
$current .= $char;
if ($char === $inQuote && ($i === 0 || $command[$i - 1] !== '\\')) {
$inQuote = null;
}
} elseif ($char === '"' || $char === "'") {
$inQuote = $char;
$current .= $char;
} elseif (\ctype_space($char)) {
if ($current !== '') {
$args[] = $current;
$current = '';
}
} else {
$current .= $char;
}
}
if ($current !== '') {
$args[] = $current;
}
return $args;
}
}

View File

@@ -0,0 +1,86 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Helper\BracketPair;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a closing bracket with skip-over logic.
*/
class InsertCloseBracketAction implements ActionInterface
{
private string $bracket;
public function __construct(string $bracket)
{
$this->bracket = $bracket;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (BracketPair::shouldSkipOver($this->bracket, $buffer)) {
if ($this->shouldDedent($buffer)) {
$this->dedentAndSkip($buffer);
} else {
$buffer->moveCursorRight(1);
}
} else {
$buffer->autoDedentIfClosingBracket($this->bracket, $buffer->getBeforeCursor());
$buffer->insert($this->bracket);
}
return true;
}
/**
* Check if we should dedent when typing a closing bracket.
*
* Dedent when:
* - Cursor is before the closing bracket
* - Current line only has whitespace before the cursor
* - We're in multi-line mode
*/
private function shouldDedent(Buffer $buffer): bool
{
$currentLine = $buffer->getCurrentLineText();
$cursorInLine = $buffer->getCursorPositionInLine();
$textBeforeCursor = \mb_substr($currentLine, 0, $cursorInLine);
return \trim($textBeforeCursor) === '' && \strpos($buffer->getText(), "\n") !== false;
}
/**
* Dedent by removing indentation before the closing bracket.
*
* Keeps the closing bracket on its own line but removes the indentation.
*/
private function dedentAndSkip(Buffer $buffer): void
{
$buffer->deleteBackward($buffer->getCursorPositionInLine());
$buffer->moveCursorRight(1);
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-close-bracket';
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert indentation when Tab is pressed in leading multiline whitespace.
*/
class InsertIndentOnTabAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!$readline->isMultilineMode()) {
return false;
}
$line = $buffer->getCurrentLineText();
$cursorInLine = $buffer->getCursorPositionInLine();
$beforeCursor = \substr($line, 0, $cursorInLine);
if (\trim($beforeCursor) !== '') {
return false;
}
$spaces = $buffer->spacesToNextTabStop($cursorInLine);
$buffer->insert(\str_repeat(' ', $spaces));
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-indent-on-tab';
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Helper\BracketPair;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a line break without executing the current buffer.
*/
class InsertLineBreakAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$indent = $buffer->calculateIndentBeforeCursor();
$expandBracket = $this->isCursorBeforeClosingBracket($buffer);
$buffer->insert("\n".$indent);
// Push the closing bracket to its own line, dedented one level.
if ($expandBracket) {
$cursorPos = $buffer->getCursor();
$buffer->insert("\n".$buffer->dedent($indent));
$buffer->setCursor($cursorPos);
}
return true;
}
/**
* Check whether the cursor is immediately before a closing bracket.
*/
private function isCursorBeforeClosingBracket(Buffer $buffer): bool
{
$afterCursor = $buffer->getAfterCursor();
return $afterCursor !== '' && \in_array($afterCursor[0], BracketPair::CLOSING_BRACKETS);
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-line-break';
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a line break when input should continue on another line.
*/
class InsertLineBreakOnIncompleteStatementAction implements ActionInterface
{
private InsertLineBreakAction $lineBreakAction;
public function __construct()
{
$this->lineBreakAction = new InsertLineBreakAction();
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($readline->isMultilineMode()) {
if ($buffer->isCompleteStatement()) {
return false;
}
return $this->lineBreakAction->execute($buffer, $terminal, $readline);
}
if ($buffer->isEmpty() || $buffer->isCompleteStatement()) {
return false;
}
$line = $buffer->getText();
if ($readline->isCommand($line) && !$readline->isInOpenStringOrComment($line)) {
return false;
}
return $this->lineBreakAction->execute($buffer, $terminal, $readline);
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-line-break-on-incomplete-statement';
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a line break when smart brackets detect unclosed pairs.
*/
class InsertLineBreakOnUnclosedBracketsAction implements ActionInterface
{
private bool $smartBrackets;
private InsertLineBreakAction $lineBreakAction;
public function __construct(bool $smartBrackets = false)
{
$this->smartBrackets = $smartBrackets;
$this->lineBreakAction = new InsertLineBreakAction();
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!$this->smartBrackets || !$buffer->hasUnclosedBracketsBeforeCursor()) {
return false;
}
// If the full buffer is semantically complete, let it submit
// even if the cursor is between brackets.
if ($buffer->isCompleteStatement()) {
return false;
}
return $this->lineBreakAction->execute($buffer, $terminal, $readline);
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-line-break-on-unclosed-brackets';
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Helper\BracketPair;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert an opening bracket with auto-closing.
*/
class InsertOpenBracketAction implements ActionInterface
{
private string $bracket;
public function __construct(string $bracket)
{
$this->bracket = $bracket;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($this->shouldDedentForControlStructureBrace($buffer)) {
$this->dedentForControlStructureBrace($buffer);
}
if (BracketPair::shouldAutoClose($this->bracket, $buffer)) {
$closingBracket = BracketPair::getClosingBracket($this->bracket);
$buffer->insert($this->bracket.$closingBracket);
$buffer->moveCursorLeft(1);
} else {
$buffer->insert($this->bracket);
}
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-open-bracket';
}
/**
* Detect Allman-style opening braces after a control structure line.
*/
private function shouldDedentForControlStructureBrace(Buffer $buffer): bool
{
if ($this->bracket !== '{') {
return false;
}
$currentLine = $buffer->getCurrentLineText();
$cursorInLine = $buffer->getCursorPositionInLine();
$textBeforeCursor = \mb_substr($currentLine, 0, $cursorInLine);
if (\trim($currentLine) !== '' || \trim($textBeforeCursor) !== '' || $cursorInLine !== \mb_strlen($currentLine)) {
return false;
}
$linesBeforeCursor = \explode("\n", $buffer->getBeforeCursor());
\array_pop($linesBeforeCursor);
$previousLine = \end($linesBeforeCursor);
if ($previousLine === false) {
return false;
}
return (bool) \preg_match('/^\s*(?:(?:if|for|foreach|while|switch|elseif)\s*\([^)]*\)|else|do)\s*$/', $previousLine);
}
/**
* Align the opening brace with the preceding control structure line.
*/
private function dedentForControlStructureBrace(Buffer $buffer): void
{
$cursorInLine = $buffer->getCursorPositionInLine();
$indent = \mb_substr($buffer->getCurrentLineText(), 0, $cursorInLine);
$dedentedIndent = $buffer->dedent($indent);
$buffer->deleteBackward($cursorInLine);
$buffer->insert($dedentedIndent);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Helper\BracketPair;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a quote with auto-closing and skip-over logic.
*/
class InsertQuoteAction implements ActionInterface
{
private string $quote;
public function __construct(string $quote)
{
$this->quote = $quote;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (BracketPair::shouldSkipOver($this->quote, $buffer)) {
$buffer->moveCursorRight(1);
return true;
}
if (BracketPair::shouldAutoClose($this->quote, $buffer)) {
$buffer->insert($this->quote.$this->quote);
$buffer->moveCursorLeft(1);
} else {
$buffer->insert($this->quote);
}
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'insert-quote';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Kill text from cursor to end of line (Ctrl-K).
*/
class KillLineAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deleteToEnd();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'kill-line';
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Delete backward by PHP token (kill previous token).
*
* Provides semantic, token-based deletion that understands PHP syntax.
* Deletes the previous token (variables, operators, method names, etc.)
* instead of generic words.
*/
class KillTokenAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deletePreviousToken();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'kill-token';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Kill text from start of line to cursor (Ctrl-U).
*/
class KillWholeLineAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deleteToStart();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'kill-whole-line';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Kill the word before the cursor (Ctrl-W).
*/
class KillWordAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->deletePreviousWord();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'kill-word';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor left one character.
*/
class MoveLeftAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->moveCursorLeft();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-left';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor right one character.
*/
class MoveRightAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->moveCursorRight();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-right';
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor to end of current line (Ctrl-E, End).
*/
class MoveToEndAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$buffer->moveCursorToEndOfCurrentLine();
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-to-end';
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Smart Home: toggle between first non-whitespace character and column zero (Ctrl-A, Home).
*/
class MoveToStartAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$line = $buffer->getCurrentLineText();
$cursorInLine = $buffer->getCursorPositionInLine();
$lineStart = $buffer->getCursor() - $cursorInLine;
// For whitespace-only lines, treat the whole line as having no indent
$firstNonWhitespace = \strspn($line, " \t");
if ($firstNonWhitespace === \strlen($line)) {
$firstNonWhitespace = 0;
}
$targetColumn = $cursorInLine === $firstNonWhitespace ? 0 : $firstNonWhitespace;
$buffer->setCursor($lineStart + $targetColumn);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-to-start';
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor backward by PHP token.
*
* Provides semantic, token-based navigation that understands PHP syntax.
* Stops at each token boundary (variables, operators, method names, etc.)
* instead of generic word boundaries.
*/
class MoveTokenLeftAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$tokenStart = $buffer->findPreviousToken();
$buffer->setCursor($tokenStart);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-token-left';
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor forward by PHP token.
*
* Provides semantic, token-based navigation that understands PHP syntax.
* Stops at each token boundary (variables, operators, method names, etc.)
* instead of generic word boundaries.
*/
class MoveTokenRightAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$tokenStart = $buffer->findNextToken();
$buffer->setCursor($tokenStart);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-token-right';
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor backward by word (Alt+Left, Ctrl+Left, Alt+B).
*/
class MoveWordLeftAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$wordStart = $buffer->findPreviousWord();
$buffer->setCursor($wordStart);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-word-left';
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Move cursor forward by word (Alt+Right, Ctrl+Right, Alt+F).
*/
class MoveWordRightAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$wordEnd = $buffer->findNextWord();
$buffer->setCursor($wordEnd);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'move-word-right';
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Navigate to next history entry (Down arrow).
*
* Fish/ZSH-style behavior: first moves within soft-wrapped visual rows, then
* in multi-line mode moves to the next logical line. Only navigates to next
* history entry when already at the bottom of the buffer.
*/
class NextHistoryAction implements ActionInterface
{
private History $history;
public function __construct(History $history)
{
$this->history = $history;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($buffer->moveToNextVisualRow(
$terminal->getWidth(),
$readline->getPromptWidthForCurrentLine($buffer)
)) {
return true;
}
if ($readline->isMultilineMode() && !$buffer->isOnLastLine()) {
$buffer->moveToNextLine();
return true;
}
if (!$this->history->isInHistory()) {
$terminal->bell();
return true;
}
$entry = $this->history->getNext();
if ($entry !== null) {
$buffer->setText($entry);
} else {
$terminal->bell();
}
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'next-history';
}
}

View File

@@ -0,0 +1,86 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Navigate to previous history entry (Up arrow).
*
* Fish/ZSH-style behavior: first moves within soft-wrapped visual rows, then
* in multi-line mode moves to the previous logical line. Only navigates to
* previous history entry when already at the top of the buffer.
*/
class PreviousHistoryAction implements ActionInterface
{
private History $history;
public function __construct(History $history)
{
$this->history = $history;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($buffer->moveToPreviousVisualRow(
$terminal->getWidth(),
$readline->getPromptWidthForCurrentLine($buffer)
)) {
return true;
}
if ($readline->isMultilineMode() && !$buffer->isOnFirstLine()) {
$buffer->moveToPreviousLine();
return true;
}
$enteringHistory = !$this->history->isInHistory();
$text = $buffer->getText();
// Set the search term when first entering history navigation
if ($enteringHistory) {
$this->history->setSearchTerm($text !== '' ? $text : null);
}
$entry = $this->history->getPrevious();
if ($entry === null) {
$terminal->bell();
return true;
}
// Save current input before navigating away so we can restore it
if ($enteringHistory && $text !== '') {
$this->history->saveTemporaryEntry($text);
}
$buffer->setText($entry);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'previous-history';
}
}

View File

@@ -0,0 +1,84 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Reject submit when the buffer has an unrecoverable syntax error.
*
* Sets the input frame to error mode and rings the bell so the user can
* fix the error in-place, rather than inserting a newline or submitting.
*/
class RejectSyntaxErrorAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if (!$buffer->hasUnrecoverableSyntaxError()) {
return false;
}
$line = $buffer->getText();
if ($readline->isCommand($line) && !$readline->isInOpenStringOrComment($line)) {
return false;
}
// Don't reject when there are truly unclosed brackets before
// cursor (more opens than closes). In that case, the user is
// still typing inside a bracket pair and the unclosed-brackets
// action should handle continuation instead.
if ($this->hasOpenBracketsBeforeCursor($buffer)) {
return false;
}
$readline->setInputFrameError(true);
$terminal->bell();
return true;
}
/**
* Check if text before cursor has more opening brackets than closing.
*/
private function hasOpenBracketsBeforeCursor(Buffer $buffer): bool
{
$text = $buffer->getBeforeCursor();
if (\trim($text) === '') {
return false;
}
$tokens = @\token_get_all('<?php '.$text);
$depth = 0;
$pairs = ['(' => 1, ')' => -1, '[' => 1, ']' => -1, '{' => 1, '}' => -1];
foreach ($tokens as $token) {
if (\is_string($token) && isset($pairs[$token])) {
$depth += $pairs[$token];
}
}
return $depth > 0;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'reject-syntax-error';
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\HistorySearch;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Action for interactive reverse history search (Ctrl-R).
*/
class ReverseSearchAction implements ActionInterface
{
private HistorySearch $search;
public function __construct(HistorySearch $search)
{
$this->search = $search;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($this->search->isActive()) {
$this->search->findNext();
} else {
$readline->clearSuggestion();
$this->search->saveBuffer($buffer);
$this->search->enter($buffer->getCurrentLineText());
}
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'reverse-search-history';
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Insert a character at the cursor position.
*/
class SelfInsertAction implements ActionInterface
{
private string $char;
public function __construct(string $char)
{
$this->char = $char;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$context = $buffer->getText();
$buffer->autoDedentIfClosingBracket($this->char, $context);
$buffer->insert($this->char);
return true;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'self-insert';
}
}

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Submit the current line for evaluation.
*/
class SubmitLineAction implements ActionInterface
{
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
$line = $buffer->getText();
// Move from current input line to below any outer frame rows.
$lineCount = \substr_count($line, "\n") + 1;
$remainingInputLines = $lineCount - $buffer->getCurrentLineNumber();
$outerRows = $readline->getInputFrameOuterRowCount();
$escapeRows = $remainingInputLines + $outerRows;
$terminal->write(\str_repeat("\n", $escapeRows));
$readline->setLastSubmitEscapeRows($escapeRows);
return false;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'submit-line';
}
}

View File

@@ -0,0 +1,546 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Actions;
use Psy\Completion\CompletionEngine;
use Psy\Completion\CompletionRequest;
use Psy\Completion\FuzzyMatcher;
use Psy\Readline\Interactive\Helper\CompletionRenderer;
use Psy\Readline\Interactive\Helper\CurrentWord;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Readline;
use Psy\Readline\Interactive\Terminal;
/**
* Tab completion action.
*
* Triggers context-aware tab completion when the Tab key is pressed.
*/
class TabAction implements ActionInterface
{
private ?CompletionEngine $completer = null;
private bool $smartBrackets = false;
/** @var string[] */
private array $currentMatches = [];
/** @var string[] */
private array $filteredMatches = [];
private int $selectedIndex = 0;
private string $filterText = '';
private bool $inInteractiveMode = false;
private ?bool $interactiveSelectionEnabled = null;
/** @var int Viewport: first visible row when scrolling. */
private int $scrollOffset = 0;
/** @var bool Viewport: whether the menu has expanded to full height. */
private bool $expanded = false;
private int $totalRows = 0;
private int $totalColumns = 0;
public function __construct(?CompletionEngine $completer = null, bool $smartBrackets = false)
{
$this->completer = $completer;
$this->smartBrackets = $smartBrackets;
}
/**
* Set the CompletionEngine instance.
*/
public function setCompleter(CompletionEngine $completer): void
{
$this->completer = $completer;
}
/**
* Force-enable or disable interactive menu mode (for tests).
*
* @internal
*/
public function setInteractiveSelectionEnabled(?bool $enabled): void
{
$this->interactiveSelectionEnabled = $enabled;
}
/**
* {@inheritdoc}
*/
public function execute(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($this->completer === null) {
$terminal->bell();
return true;
}
$text = $buffer->getText();
$cursor = $buffer->getCursor();
$matches = $this->completer->getCompletions(
new CompletionRequest($text, $cursor, CompletionRequest::MODE_TAB)
);
if (empty($matches)) {
return true;
}
if (\count($matches) === 1) {
$this->insertMatch($buffer, $matches[0]);
return true;
}
$commonPrefix = $this->getCommonPrefix($matches);
$currentWord = CurrentWord::extract($text, $cursor);
if (!empty($commonPrefix) && $commonPrefix !== $currentWord) {
$this->insertMatch($buffer, $commonPrefix);
$currentWord = $commonPrefix;
}
$this->currentMatches = \array_values($matches);
$this->filterText = $currentWord;
$this->filteredMatches = $this->currentMatches;
$this->selectedIndex = 0;
$this->scrollOffset = 0;
$this->expanded = false;
$this->inInteractiveMode = true;
$readline->enterMenuMode();
try {
$this->updateOverlay($buffer, $terminal, $readline);
return $this->handleInteractiveSelection($buffer, $terminal, $readline);
} finally {
$readline->exitMenuMode();
}
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'tab-completion';
}
/**
* Insert a match at the cursor position.
*/
private function insertMatch(Buffer $buffer, string $match): void
{
$text = $buffer->getText();
$cursor = $buffer->getCursor();
$textLen = \mb_strlen($text);
$start = $cursor;
$hasVarPrefix = false;
while ($start > 0) {
$char = \mb_substr($text, $start - 1, 1);
if (\ctype_space($char)) {
break;
}
if ($char === '>' && $start >= 2 && \mb_substr($text, $start - 2, 1) === '-') {
break;
}
if ($char === ':' && $start >= 2 && \mb_substr($text, $start - 2, 1) === ':') {
break;
}
if ($char === '$') {
$hasVarPrefix = true;
$start--;
break;
}
$start--;
}
$end = $cursor;
while ($end < $textLen) {
$char = \mb_substr($text, $end, 1);
if (\ctype_space($char) || (!\ctype_alnum($char) && $char !== '_')) {
break;
}
$end++;
}
if ($hasVarPrefix && $match[0] !== '$') {
$match = '$'.$match;
}
$addParens = $this->smartBrackets && $this->shouldAddParentheses($match);
$before = \mb_substr($text, 0, $start);
$after = \mb_substr($text, $end);
if ($addParens) {
$buffer->setText($before.$match.'()'.$after);
// Place cursor after parens for zero-arg functions, inside for others
$offset = $this->functionHasParameters($match) ? 1 : 2;
$buffer->setCursor($start + \mb_strlen($match) + $offset);
} else {
$buffer->setText($before.$match.$after);
$buffer->setCursor($start + \mb_strlen($match));
}
}
/**
* Determine if parentheses should be added for this completion.
*/
private function shouldAddParentheses(string $match): bool
{
if (\substr($match, 0, 1) === '$') {
return false;
}
if (\strtoupper($match) === $match && \ctype_upper($match[0])) {
return false;
}
return \function_exists($match);
}
/**
* Check whether a function accepts any parameters (required or optional).
*/
private function functionHasParameters(string $name): bool
{
try {
return (new \ReflectionFunction($name))->getNumberOfParameters() > 0;
} catch (\ReflectionException $e) {
// If we can't reflect it, assume it has parameters
return true;
}
}
/**
* Handle interactive selection with arrow keys and filtering.
*/
private function handleInteractiveSelection(Buffer $buffer, Terminal $terminal, Readline $readline): bool
{
if ($this->interactiveSelectionEnabled !== null) {
$isTTY = $this->interactiveSelectionEnabled;
} else {
$isTTY = @\stream_isatty(\STDIN);
}
if (!$isTTY) {
$this->inInteractiveMode = false;
return true;
}
while ($this->inInteractiveMode) {
$key = $readline->readNextKey();
$keyStr = (string) $key;
$keyValue = $key->getValue();
if ($keyStr === 'escape:[A' || $keyValue === "\x10") { // Up or Ctrl-P
$this->moveSelection(-1, $readline);
$this->updateOverlay($buffer, $terminal, $readline);
} elseif ($keyStr === 'escape:[B' || $keyValue === "\x0e") { // Down or Ctrl-N
$this->moveSelection(1, $readline);
$this->updateOverlay($buffer, $terminal, $readline);
} elseif ($keyStr === 'escape:[C' || $keyValue === "\x06") { // Right or Ctrl-F
$this->moveSelectionHorizontal(1, $readline);
$this->updateOverlay($buffer, $terminal, $readline);
} elseif ($keyStr === 'escape:[D' || $keyValue === "\x02") { // Left or Ctrl-B
$this->moveSelectionHorizontal(-1, $readline);
$this->updateOverlay($buffer, $terminal, $readline);
} elseif ($keyValue === "\t") {
// Tab cycles to the next match
$this->moveSelection(1, $readline);
$this->updateOverlay($buffer, $terminal, $readline);
} elseif ($keyValue === "\r" || $keyValue === "\n") {
$this->inInteractiveMode = false;
if (!empty($this->filteredMatches)) {
$selected = $this->filteredMatches[$this->selectedIndex];
$this->insertMatch($buffer, $selected);
}
$readline->clearOverlay($buffer);
break;
} elseif ($keyValue === "\e" || $keyValue === "\x03") { // Escape or Ctrl-C
$this->inInteractiveMode = false;
$readline->clearOverlay($buffer);
break;
} elseif ($keyValue === "\x7f" || $keyValue === "\x08") { // Backspace
if ($buffer->getCursor() > 0) {
$buffer->deleteBackward();
$this->filterText = CurrentWord::extract($buffer->getText(), $buffer->getCursor());
if ($this->filterText === '') {
$this->inInteractiveMode = false;
$readline->clearOverlay($buffer);
break;
}
$this->updateFilter($terminal);
$this->updateOverlay($buffer, $terminal, $readline);
}
} elseif ($key->isChar() && \strlen($keyValue) === 1 && $keyValue >= ' ' && $keyValue <= '~') {
$buffer->insert($keyValue);
if ($keyValue === ' ') {
$this->inInteractiveMode = false;
$readline->clearOverlay($buffer);
break;
}
$this->filterText = CurrentWord::extract($buffer->getText(), $buffer->getCursor());
$this->updateFilter($terminal);
$this->updateOverlay($buffer, $terminal, $readline);
} else {
$this->inInteractiveMode = false;
$readline->clearOverlay($buffer);
$readline->replayKey($key);
break;
}
}
return true;
}
/**
* Generate overlay lines from matches and render the frame.
*/
private function updateOverlay(Buffer $buffer, Terminal $terminal, Readline $readline): void
{
// Terminal width may change while the menu is open.
$this->updateLayout($terminal);
$maxRows = $this->getMaxRows($readline);
if ($maxRows === null) {
$this->scrollOffset = 0;
} else {
$maxOffset = \max(0, $this->totalRows - $maxRows);
$this->scrollOffset = \max(0, \min($this->scrollOffset, $maxOffset));
}
$renderer = new CompletionRenderer($terminal);
$lines = $renderer->render($this->filteredMatches, $this->selectedIndex, $maxRows, $this->scrollOffset, !$this->expanded);
// Prepend a blank separator line above the menu
$readline->renderOverlay($buffer, \array_merge([''], $lines));
}
/**
* Update filtered matches based on current filter text.
*/
private function updateFilter(?Terminal $terminal = null): void
{
$previousSelection = $this->filteredMatches[$this->selectedIndex] ?? null;
if ($this->filterText === '') {
$this->filteredMatches = $this->currentMatches;
} else {
$this->filteredMatches = FuzzyMatcher::filter($this->filterText, $this->currentMatches);
}
// Keep the same item selected if it survived filtering, otherwise
// reset to the top where the best matches are.
$newIndex = \array_search($previousSelection, $this->filteredMatches, true);
$this->selectedIndex = $newIndex !== false ? $newIndex : 0;
// Reset viewport when filter changes
$this->scrollOffset = 0;
$this->expanded = false;
if ($terminal !== null) {
$this->updateLayout($terminal);
}
}
/**
* Recalculate total row count for the current matches.
*/
private function updateLayout(Terminal $terminal): void
{
if (empty($this->filteredMatches)) {
$this->totalRows = 0;
$this->totalColumns = 0;
return;
}
$renderer = new CompletionRenderer($terminal);
$layout = $renderer->calculateLayout($this->filteredMatches);
$this->totalRows = $layout['rows'];
$this->totalColumns = $layout['columns'];
}
/**
* Get the maximum visible rows for the current viewport state.
*
* Returns null if all rows fit without truncation.
*/
private function getMaxRows(Readline $readline): ?int
{
$available = $readline->getOverlayAvailableRows(!$this->expanded);
// Account for the blank separator line above the menu
$menuBudget = \max(1, $available - 1);
if ($this->totalRows <= $menuBudget) {
// No truncation needed if everything fits (including the +1 off-by-one:
// don't show a status line when the last row would fit in its place)
return null;
}
// When truncated, reserve one row for the status line
return \max(1, $menuBudget - 1);
}
/**
* Move selection up or down, adjusting viewport as needed.
*/
private function moveSelection(int $delta, Readline $readline): void
{
$count = \count($this->filteredMatches);
if ($count === 0) {
return;
}
$newIndex = $this->selectedIndex + $delta;
// Wrap around
if ($newIndex < 0) {
$newIndex = $count - 1;
} elseif ($newIndex >= $count) {
$newIndex = 0;
}
$this->selectedIndex = $newIndex;
$this->adjustViewport($readline);
}
/**
* Adjust scroll offset so the selected item is visible.
*/
private function adjustViewport(Readline $readline): void
{
$maxRows = $this->getMaxRows($readline);
// Everything fits, no scrolling needed
if ($maxRows === null) {
$this->scrollOffset = 0;
return;
}
$selectedRow = $this->selectedIndex % $this->totalRows;
// Selection is above the visible window
if ($selectedRow < $this->scrollOffset) {
$this->scrollOffset = $selectedRow;
return;
}
// Selection is below the visible window
if ($selectedRow >= $this->scrollOffset + $maxRows) {
if (!$this->expanded) {
$this->expanded = true;
$maxRows = $this->getMaxRows($readline);
// After expanding, everything might fit
if ($maxRows === null) {
$this->scrollOffset = 0;
return;
}
}
// Scroll to keep selection visible at the bottom
if ($selectedRow >= $this->scrollOffset + $maxRows) {
$this->scrollOffset = $selectedRow - $maxRows + 1;
}
}
// Clamp scroll offset
$this->scrollOffset = \max(0, \min($this->scrollOffset, $this->totalRows - $maxRows));
}
/**
* Move selection left or right (horizontal between columns).
*/
private function moveSelectionHorizontal(int $delta, Readline $readline): void
{
$count = \count($this->filteredMatches);
if ($count === 0) {
return;
}
$rows = $this->totalRows;
$columns = $this->totalColumns;
$currentRow = $this->selectedIndex % $rows;
$currentCol = \intdiv($this->selectedIndex, $rows);
$newCol = $currentCol + $delta;
// Wrap to next/previous row at column boundaries
if ($newCol >= $columns || ($currentRow + ($newCol * $rows)) >= $count) {
$newCol = 0;
$currentRow++;
if ($currentRow >= $rows) {
$currentRow = 0;
}
} elseif ($newCol < 0) {
$newCol = $columns - 1;
$currentRow--;
if ($currentRow < 0) {
$currentRow = $rows - 1;
}
}
$newIndex = $currentRow + ($newCol * $rows);
// If we landed past the end (sparse last row), fall back to last column on this row
if ($newIndex >= $count) {
while ($newCol > 0 && $newIndex >= $count) {
$newCol--;
$newIndex = $currentRow + ($newCol * $rows);
}
}
$this->selectedIndex = $newIndex;
$this->adjustViewport($readline);
}
/**
* Get the common prefix of all matches.
*
* @todo Should this be grapheme cluster aware?
*
* @param string[] $matches
*/
private function getCommonPrefix(array $matches): string
{
if (empty($matches)) {
return '';
}
$first = \array_shift($matches);
$prefix = '';
for ($i = 0; $i < \strlen($first); $i++) {
$char = $first[$i];
foreach ($matches as $match) {
if (!isset($match[$i]) || $match[$i] !== $char) {
return $prefix;
}
}
$prefix .= $char;
}
return $prefix;
}
}

View File

@@ -0,0 +1,134 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PhpParser\PrettyPrinter\Standard as Printer;
/**
* AST visitor to extract function/method call arguments.
*
* Returns an array where the first element is the callable (function/method name)
* and subsequent elements are the arguments.
*/
class ArgumentExtractorVisitor extends NodeVisitorAbstract
{
private Printer $printer;
/** @var string[] Extracted arguments */
private array $arguments = [];
private bool $foundCall = false;
public function __construct(Printer $printer)
{
$this->printer = $printer;
}
public function enterNode(Node $node)
{
if ($this->foundCall) {
return null;
}
$callable = $this->resolveCallable($node);
if ($callable !== null) {
$this->foundCall = true;
$this->arguments[] = $callable;
if ($node instanceof Node\Expr\FuncCall || $node instanceof Node\Expr\MethodCall || $node instanceof Node\Expr\StaticCall || $node instanceof Node\Expr\New_) {
$this->extractArgs($node);
}
}
return null;
}
/**
* Resolve the callable name for a function/method call or new expression.
*/
private function resolveCallable(Node $node): ?string
{
if ($node instanceof Node\Expr\FuncCall) {
if ($node->name instanceof Node\Name) {
return $node->name->toString();
}
return $this->printer->prettyPrintExpr($node->name);
}
if ($node instanceof Node\Expr\MethodCall) {
return $this->printer->prettyPrintExpr($node->var).'->'.$this->resolveMethodName($node->name);
}
if ($node instanceof Node\Expr\StaticCall) {
return $this->resolveClassName($node->class).'::'.$this->resolveMethodName($node->name);
}
if ($node instanceof Node\Expr\New_) {
return 'new '.$this->resolveClassName($node->class);
}
return null;
}
/**
* Resolve a class name from a Name node or expression.
*
* @param Node\Name|Node\Expr $class
*/
private function resolveClassName(Node $class): string
{
if ($class instanceof Node\Name) {
return $class->toString();
}
return $this->printer->prettyPrintExpr($class);
}
/**
* Resolve a method name from an Identifier node or expression.
*
* @param Node\Identifier|Node\Expr $name
*/
private function resolveMethodName(Node $name): string
{
if ($name instanceof Node\Identifier) {
return $name->name;
}
return $this->printer->prettyPrintExpr($name);
}
/**
* @param Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $node
*/
private function extractArgs($node): void
{
if (!\is_array($node->args)) {
return;
}
foreach ($node->args as $arg) {
if ($arg instanceof Node\Arg) {
$this->arguments[] = $this->printer->prettyPrintExpr($arg->value);
}
}
}
/**
* @return string[]
*/
public function getArguments(): array
{
return $this->arguments;
}
}

View File

@@ -0,0 +1,221 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use Psy\Readline\Interactive\Input\Buffer;
/**
* Bracket pairing helper.
*
* Stateless helper that determines when to auto-close brackets,
* skip over closing brackets, and delete bracket pairs on backspace.
*/
class BracketPair
{
public const OPENING_BRACKETS = ['(', '[', '{'];
public const CLOSING_BRACKETS = [')', ']', '}'];
private const PAIRS = [
'(' => ')',
'[' => ']',
'{' => '}',
'"' => '"',
"'" => "'",
];
private const CLOSING_TO_OPENING = [
')' => '(',
']' => '[',
'}' => '{',
];
/**
* Should we auto-close this opening bracket?
*/
public static function shouldAutoClose(string $char, Buffer $buffer): bool
{
if (!isset(self::PAIRS[$char])) {
return false;
}
if (self::isQuote($char) && self::isInsideString($buffer)) {
return false;
}
// Don't auto-close if next char is alphanumeric, e.g. typing "ar(ray"
$nextChar = $buffer->getCharAfterCursor();
if ($nextChar !== null && self::startsWithAlnum($nextChar)) {
return false;
}
return true;
}
/**
* Should we skip over the closing bracket instead of inserting?
*/
public static function shouldSkipOver(string $char, Buffer $buffer): bool
{
$nextChar = $buffer->getCharAfterCursor();
return $nextChar === $char;
}
/**
* Should backspace delete the matching closing bracket?
*/
public static function shouldDeletePair(Buffer $buffer): bool
{
$before = $buffer->getCharBeforeCursor();
$after = $buffer->getCharAfterCursor();
if ($before === null || $after === null) {
return false;
}
return isset(self::PAIRS[$before]) && self::PAIRS[$before] === $after;
}
/**
* Get the closing bracket for this opening bracket.
*/
public static function getClosingBracket(string $openingBracket): ?string
{
return self::PAIRS[$openingBracket] ?? null;
}
/**
* Check if a closing bracket matches the most recent unclosed opening bracket.
*
* Tokenizes the code to respect strings and comments when tracking brackets.
*/
public static function doesClosingBracketMatch(string $closingChar, string $code): bool
{
if (!isset(self::CLOSING_TO_OPENING[$closingChar])) {
return false;
}
$expectedOpening = self::CLOSING_TO_OPENING[$closingChar];
$tokens = \token_get_all('<?php '.$code);
$stack = [];
foreach ($tokens as $token) {
if (\is_array($token)) {
$type = $token[0];
if ($type === \T_CONSTANT_ENCAPSED_STRING ||
$type === \T_ENCAPSED_AND_WHITESPACE ||
$type === \T_COMMENT ||
$type === \T_DOC_COMMENT) {
continue;
}
}
if (\is_string($token)) {
if (\in_array($token, self::OPENING_BRACKETS)) {
$stack[] = $token;
} elseif (isset(self::CLOSING_TO_OPENING[$token])) {
\array_pop($stack);
}
}
}
if (empty($stack)) {
return false;
}
return \end($stack) === $expectedOpening;
}
/**
* Check if the cursor is between empty brackets (excluding quotes).
*
* Used by deletion commands to extend deletion through empty bracket pairs.
* Example: "foo(|)" should delete "foo()"
*/
public static function isInsideEmptyBrackets(Buffer $buffer): bool
{
$before = $buffer->getCharBeforeCursor();
$after = $buffer->getCharAfterCursor();
if ($before === null || $after === null) {
return false;
}
// Only non-quote bracket pairs, not quotes like "" or ''
return \in_array($before, self::OPENING_BRACKETS)
&& self::PAIRS[$before] === $after;
}
/**
* Check whether a character is a quote.
*/
private static function isQuote(string $char): bool
{
return $char === '"' || $char === "'";
}
/**
* Check whether the grapheme cluster starts with an alphanumeric code point.
*/
private static function startsWithAlnum(string $char): bool
{
$firstCodePoint = \mb_substr($char, 0, 1);
return $firstCodePoint !== '' && \ctype_alnum($firstCodePoint);
}
/**
* Simple heuristic to detect if cursor is inside a string.
*
* Counts quotes before cursor - if odd, we're inside a string.
* Not perfect but works for most cases.
*/
private static function isInsideString(Buffer $buffer): bool
{
$beforeCursor = $buffer->getBeforeCursor();
$doubleQuotes = self::countUnescapedQuotes($beforeCursor, '"');
$singleQuotes = self::countUnescapedQuotes($beforeCursor, "'");
return ($doubleQuotes % 2 === 1) || ($singleQuotes % 2 === 1);
}
/**
* Count unescaped quotes in the string.
*/
private static function countUnescapedQuotes(string $text, string $quote): int
{
$count = 0;
$escaped = false;
for ($i = 0; $i < \strlen($text); $i++) {
$char = $text[$i];
if ($escaped) {
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === $quote) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,382 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use Psy\CommandAware;
use Psy\Formatter\CodeFormatter;
use Psy\Input\CodeArgument;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
/**
* Highlights PsySH commands using command metadata from Symfony definitions.
*/
class CommandHighlighter implements CommandAware
{
public const STYLE_COMMAND = 'command';
public const STYLE_OPTION = 'command_option';
public const STYLE_ARGUMENT = 'command_argument';
/** @var array<string, Command> */
private array $commandMap = [];
/** @var array<string, array<string, InputOption>> */
private array $shortcutMapCache = [];
/**
* {@inheritdoc}
*/
public function setCommands(array $commands): void
{
$this->commandMap = [];
foreach ($commands as $command) {
$this->commandMap[$command->getName()] = $command;
foreach ($command->getAliases() as $alias) {
$this->commandMap[$alias] = $command;
}
}
$this->shortcutMapCache = [];
}
/**
* Check whether the given name is a known command.
*/
public function hasCommand(string $name): bool
{
return isset($this->commandMap[$name]);
}
/**
* Highlight a complete command string.
*/
public function highlight(string $text, OutputFormatterInterface $formatter): string
{
return \implode("\n", $this->highlightLines($text, $formatter));
}
/**
* Highlight a command string into ANSI-safe lines.
*
* @return string[]
*/
public function highlightLines(string $text, OutputFormatterInterface $formatter): array
{
if ($text === '') {
return [''];
}
if (!\preg_match('/^(\s*)(\S+)(.*)$/s', $text, $matches)) {
return [$text];
}
$lines = [$matches[1].$this->applyStyle($matches[2], self::STYLE_COMMAND, $formatter)];
$command = $this->resolveCommand($matches[2]);
if ($command === null) {
$this->appendText($lines, $matches[3]);
return $lines;
}
$definition = $command->getDefinition();
$shortcuts = $this->getCachedShortcutMap($matches[2], $definition);
$arguments = \array_values($definition->getArguments());
$argumentIndex = 0;
$expectingOptionValue = null;
$endOfOptions = false;
foreach ($this->tokenize($matches[3]) as $segment) {
if ($segment['type'] === 'whitespace') {
$this->appendText($lines, $segment['text']);
continue;
}
$token = $segment['text'];
if ($expectingOptionValue instanceof InputOption) {
$this->appendText($lines, $this->highlightValue($token, $formatter));
$expectingOptionValue = null;
continue;
}
if (!$endOfOptions && $token === '--') {
$this->appendText($lines, $this->applyStyle($token, self::STYLE_OPTION, $formatter));
$endOfOptions = true;
continue;
}
if (!$endOfOptions) {
$longOption = $this->matchLongOption($token, $definition, $formatter);
if ($longOption !== null) {
$this->appendText($lines, $longOption['text']);
$expectingOptionValue = $longOption['nextExpectsValue'];
continue;
}
$shortOption = $this->matchShortOption($token, $shortcuts, $formatter);
if ($shortOption !== null) {
$this->appendText($lines, $shortOption['text']);
$expectingOptionValue = $shortOption['nextExpectsValue'];
continue;
}
}
$argument = $arguments[$argumentIndex] ?? null;
if ($argument instanceof CodeArgument) {
$this->appendLines($lines, CodeFormatter::formatInputLines((string) \substr($matches[3], $segment['offset']), $formatter));
return $lines;
}
$this->appendText($lines, $this->highlightValue($token, $formatter));
if ($argument instanceof InputArgument && !$argument->isArray()) {
$argumentIndex++;
}
}
return $lines;
}
private function resolveCommand(string $name): ?Command
{
return $this->commandMap[$name] ?? null;
}
/**
* @return array<string, InputOption>
*/
private function getCachedShortcutMap(string $commandName, InputDefinition $definition): array
{
if (isset($this->shortcutMapCache[$commandName])) {
return $this->shortcutMapCache[$commandName];
}
$shortcuts = [];
foreach ($definition->getOptions() as $option) {
if (!$option->getShortcut()) {
continue;
}
foreach (\explode('|', $option->getShortcut()) as $shortcut) {
$shortcuts[$shortcut] = $option;
}
}
return $this->shortcutMapCache[$commandName] = $shortcuts;
}
/**
* @return array{text: string, nextExpectsValue: InputOption|null}|null
*/
private function matchLongOption(string $token, InputDefinition $definition, OutputFormatterInterface $formatter): ?array
{
if (!\preg_match('/^(--[A-Za-z0-9][A-Za-z0-9-]*)(?:=(.*))?$/s', $token, $matches)) {
return null;
}
$name = \substr($matches[1], 2);
if (!$definition->hasOption($name)) {
return [
'text' => $this->applyStyle($token, self::STYLE_OPTION, $formatter),
'nextExpectsValue' => null,
];
}
$option = $definition->getOption($name);
$text = $this->applyStyle($matches[1], self::STYLE_OPTION, $formatter);
if (\array_key_exists(2, $matches) && $matches[2] !== '') {
return [
'text' => $text.'='.$this->highlightValue($matches[2], $formatter),
'nextExpectsValue' => null,
];
}
return [
'text' => $text,
'nextExpectsValue' => $option->isValueRequired() ? $option : null,
];
}
/**
* @param array<string, InputOption> $shortcuts
*
* @return array{text: string, nextExpectsValue: InputOption|null}|null
*/
private function matchShortOption(string $token, array $shortcuts, OutputFormatterInterface $formatter): ?array
{
if (!\preg_match('/^-[^-].*$/', $token)) {
return null;
}
$cluster = \substr($token, 1);
$consumed = '';
for ($i = 0, $length = \strlen($cluster); $i < $length; $i++) {
$shortcut = $cluster[$i];
if (!isset($shortcuts[$shortcut])) {
return [
'text' => $this->applyStyle($token, self::STYLE_OPTION, $formatter),
'nextExpectsValue' => null,
];
}
$consumed .= $shortcut;
$option = $shortcuts[$shortcut];
if (!$option->acceptValue()) {
continue;
}
$optionText = '-'.$consumed;
$valueText = \substr($cluster, $i + 1);
if ($valueText !== '') {
return [
'text' => $this->applyStyle($optionText, self::STYLE_OPTION, $formatter).$this->highlightValue($valueText, $formatter),
'nextExpectsValue' => null,
];
}
return [
'text' => $this->applyStyle($optionText, self::STYLE_OPTION, $formatter),
'nextExpectsValue' => $option->isValueRequired() ? $option : null,
];
}
return [
'text' => $this->applyStyle($token, self::STYLE_OPTION, $formatter),
'nextExpectsValue' => null,
];
}
private function highlightValue(string $token, OutputFormatterInterface $formatter): string
{
if (\preg_match('/^[+-]?\d+(?:\.\d+)?$/', $token)) {
return $this->applyStyle($token, CodeFormatter::HIGHLIGHT_NUMBER, $formatter);
}
return $this->applyStyle($token, self::STYLE_ARGUMENT, $formatter);
}
private function applyStyle(string $text, string $style, OutputFormatterInterface $formatter): string
{
if (!$formatter->isDecorated() || !$formatter->hasStyle($style)) {
return $text;
}
return $formatter->getStyle($style)->apply($text);
}
/**
* @return array<int, array{type: string, text: string, offset: int}>
*/
private function tokenize(string $text): array
{
$tokens = [];
$length = \strlen($text);
$offset = 0;
while ($offset < $length) {
$char = $text[$offset];
if (\ctype_space($char)) {
$start = $offset;
while ($offset < $length && \ctype_space($text[$offset])) {
$offset++;
}
$tokens[] = [
'type' => 'whitespace',
'text' => \substr($text, $start, $offset - $start),
'offset' => $start,
];
continue;
}
$start = $offset;
$quote = null;
while ($offset < $length) {
$char = $text[$offset];
if ($quote !== null) {
$offset++;
if ($char === $quote && ($offset < 2 || $text[$offset - 2] !== '\\')) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
$offset++;
continue;
}
if (\ctype_space($char)) {
break;
}
$offset++;
}
$tokens[] = [
'type' => 'token',
'text' => \substr($text, $start, $offset - $start),
'offset' => $start,
];
}
return $tokens;
}
/**
* @param string[] $lines
*/
private function appendText(array &$lines, string $text): void
{
$parts = \preg_split('/\r\n?|\n/', $text);
if ($parts === false || $parts === []) {
return;
}
$lines[\count($lines) - 1] .= \array_shift($parts);
foreach ($parts as $part) {
$lines[] = $part;
}
}
/**
* @param string[] $lines
* @param string[] $extraLines
*/
private function appendLines(array &$lines, array $extraLines): void
{
if ($extraLines === []) {
return;
}
$lines[\count($lines) - 1] .= \array_shift($extraLines);
\array_push($lines, ...$extraLines);
}
}

View File

@@ -0,0 +1,218 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Layout\DisplayString;
use Psy\Readline\Interactive\Terminal;
/**
* Renders tab completion menu in compact columns.
*
* Fast manual renderer optimized for interactive completion menus.
*/
class CompletionRenderer
{
private Terminal $terminal;
public function __construct(Terminal $terminal)
{
$this->terminal = $terminal;
}
/**
* Render completion items in a compact columnar menu.
*
* @param string[] $items Completion matches
* @param int $selectedIndex Index of selected item (-1 for none)
* @param int|null $maxRows Maximum visible rows (null for unlimited)
* @param int $scrollOffset First visible row index
*
* @return string[] The rendered lines
*/
public function render(
array $items,
int $selectedIndex = -1,
?int $maxRows = null,
int $scrollOffset = 0,
bool $compact = true
): array {
if (empty($items)) {
return [$this->terminal->format(
' <whisper>(no matches)</whisper>',
)];
}
$items = \array_map([History::class, 'collapseToSingleLine'], \array_values($items));
$layout = $this->doCalculateLayout($items);
$totalRows = $layout['rows'];
$columns = $layout['columns'];
$columnWidths = $layout['columnWidths'];
$count = \count($items);
// Determine visible row range
$needsTruncation = $maxRows !== null && $totalRows > $maxRows + 1;
$startRow = $needsTruncation ? $scrollOffset : 0;
$endRow = $needsTruncation
? \min($totalRows, $startRow + $maxRows)
: $totalRows;
$lines = [];
$hasSelection = $selectedIndex >= 0;
$formatter = $this->terminal->getFormatter();
$highlightStyle = $formatter->isDecorated() && $formatter->hasStyle('input_highlight') ? $formatter->getStyle('input_highlight') : null;
for ($row = $startRow; $row < $endRow; $row++) {
$line = ' ';
for ($col = 0; $col < $columns; $col++) {
$index = $row + $col * $totalRows;
if ($index < $count) {
$colWidth = $columnWidths[$col];
$item = DisplayString::truncate($items[$index], $colWidth, true);
$itemWidth = DisplayString::width($item);
$padding = \max(0, $colWidth - $itemWidth);
if ($hasSelection && $index === $selectedIndex) {
$highlighted = $item.\str_repeat(' ', $padding);
$line .= $highlightStyle ? $highlightStyle->apply($highlighted) : $highlighted;
} else {
$line .= $item.\str_repeat(' ', $padding);
}
if ($col < $columns - 1) {
$line .= ' ';
}
}
}
$lines[] = $line;
}
if ($needsTruncation) {
$lines[] = $this->renderStatusLine(
$startRow,
$endRow,
$totalRows,
$compact,
);
}
return $lines;
}
/**
* Calculate the column layout for a set of items.
*
* @param string[] $items Completion matches
*
* @return array{rows: int, columns: int, columnWidths: int[]}
*/
public function calculateLayout(array $items): array
{
return $this->doCalculateLayout(\array_map([History::class, 'collapseToSingleLine'], $items));
}
/**
* @param string[] $items Display-ready (single-line) items
*
* @return array{rows: int, columns: int, columnWidths: int[]}
*/
private function doCalculateLayout(array $items): array
{
$widths = \array_map([DisplayString::class, 'width'], $items);
$count = \count($items);
$maxWidth = $this->terminal->getWidth();
// Start with a naive guess based on the widest item
$columns = \max(1, \intdiv($maxWidth, \max($widths) + 2));
$columnWidths = $this->calculateColumnWidths($widths, $count, $columns);
$maxColumns = \min($count, $columns + 5);
// Check up to five more columns, to see if there's a more optimal
// layout after wrapping.
for ($try = $columns + 1; $try <= $maxColumns; $try++) {
$candidate = $this->calculateColumnWidths($widths, $count, $try);
if (\array_sum($candidate) + ($try - 1) * 2 + 3 <= $maxWidth) {
$columns = $try;
$columnWidths = $candidate;
}
}
// Cap single-column width so wide items don't soft-wrap.
// Multi-column layouts are already validated by the loop above.
if ($columns === 1) {
$columnWidths[0] = \min($columnWidths[0], $maxWidth - 3);
}
return [
'rows' => $this->calculateRowCount($count, $columns),
'columns' => $columns,
'columnWidths' => $columnWidths,
];
}
/**
* Render the status line for truncated menus.
*/
private function renderStatusLine(
int $startRow,
int $endRow,
int $totalRows,
bool $compact
): string {
if ($compact && $startRow === 0) {
$remaining = $totalRows - $endRow;
$text = \sprintf('…and %d more rows', $remaining);
} else {
$text = \sprintf(
'rows %d to %d of %d',
$startRow + 1,
$endRow,
$totalRows,
);
}
return $this->terminal->format(' <whisper>'.$text.'</whisper>');
}
/**
* Calculate per-column widths for a given column count.
*
* @param int[] $widths Pre-computed item widths
* @param int $count Total item count
* @param int $columns Number of columns
*
* @return int[] Width of each column
*/
private function calculateColumnWidths(
array $widths,
int $count,
int $columns
): array {
$rows = $this->calculateRowCount($count, $columns);
$columnWidths = \array_fill(0, $columns, 0);
for ($i = 0; $i < $count; $i++) {
$col = \intdiv($i, $rows);
$columnWidths[$col] = \max($columnWidths[$col], $widths[$i]);
}
return $columnWidths;
}
/**
* Calculate the number of rows needed for a column-first layout.
*/
private function calculateRowCount(int $itemCount, int $columns): int
{
return \intdiv($itemCount + $columns - 1, $columns);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
/**
* Shared current-word extraction for completion and suggestion flows.
*/
final class CurrentWord
{
/**
* Extract the word immediately before the cursor.
*
* Word boundaries stop at whitespace, '$', '->', and '::'.
*/
public static function extract(string $line, int $position): string
{
$length = \mb_strlen($line);
$position = \max(0, \min($position, $length));
if ($position === 0 || $line === '') {
return '';
}
$start = $position;
while ($start > 0) {
$char = \mb_substr($line, $start - 1, 1);
if ($char === '' || \ctype_space($char) || $char === '$') {
break;
}
if ($start >= 2) {
$prev = \mb_substr($line, $start - 2, 1);
if (($prev === '-' && $char === '>') || ($prev === ':' && $char === ':')) {
break;
}
}
$start--;
}
return \mb_substr($line, $start, $position - $start);
}
}

View File

@@ -0,0 +1,158 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Debug logging utility for interactive readline.
*
* All methods are no-ops unless explicitly enabled at DEBUG verbosity.
* When enabled, creates a temp file and logs state changes, completion
* matching, suggestion behavior, etc.
*
* Enable via PsySH verbosity level (-vvv):
* bin/psysh -vvv
*/
class DebugLog
{
/** @var resource|null */
private static $handle = null;
private static bool $enabled = false;
private static string $logPath = '';
/**
* Enable debug logging if verbosity is at DEBUG level.
*/
public static function enable(int $verbosity): void
{
if ($verbosity < OutputInterface::VERBOSITY_DEBUG) {
return;
}
$path = @\tempnam(\sys_get_temp_dir(), 'psysh-debug-');
if ($path === false) {
return;
}
$handle = @\fopen($path, 'a');
if ($handle === false) {
@\unlink($path);
return;
}
self::$enabled = true;
self::$logPath = $path;
self::$handle = $handle;
\register_shutdown_function(static function () {
if (self::$handle !== null && \is_resource(self::$handle)) {
\fclose(self::$handle);
self::$handle = null;
}
});
}
/**
* Log a debug message.
*
* @param string $category Category/component (e.g., "Suggestion", "Completion")
* @param string $message Concise message describing the event
* @param array $context Optional key-value context data
*/
public static function log(string $category, string $message, $context = []): void
{
if (!self::$enabled) {
return;
}
$contextStr = '';
if (!empty($context)) {
if (\is_array($context)) {
$parts = [];
foreach ($context as $key => $value) {
if (\is_string($value)) {
$parts[] = "{$key}=\"".self::escapeValue($value).'"';
} elseif (\is_bool($value)) {
$parts[] = "{$key}=".($value ? 'true' : 'false');
} elseif ($value === null) {
$parts[] = "{$key}=null";
} else {
$parts[] = "{$key}={$value}";
}
}
$contextStr = ' ('.\implode(', ', $parts).')';
} else {
$contextStr = ' ('.self::escapeValue((string) $context).')';
}
}
self::writeLine("{$category}: {$message}{$contextStr}");
}
/**
* Log a separator for readability.
*/
public static function separator(string $label = ''): void
{
if (!self::$enabled) {
return;
}
$sep = \str_repeat('-', 60);
self::writeLine($label ? "{$sep} {$label} {$sep}" : $sep);
}
/**
* Write a timestamped line to the log file.
*/
private static function writeLine(string $content): void
{
if (self::$handle !== null && \is_resource(self::$handle)) {
$timestamp = \date('H:i:s');
\fwrite(self::$handle, "[{$timestamp}] {$content}\n");
\fflush(self::$handle);
}
}
/**
* Escape non-printable bytes for safe debug output.
*/
private static function escapeValue(string $value): string
{
$escaped = \addcslashes($value, "\0..\37\177..\377\\\"");
if (\strlen($escaped) > 200) {
return \substr($escaped, 0, 200).'...';
}
return $escaped;
}
/**
* Get the log file path.
*/
public static function getLogPath(): string
{
return self::$logPath;
}
/**
* Check if debug logging is enabled.
*/
public static function isEnabled(): bool
{
return self::$enabled;
}
}

View File

@@ -0,0 +1,127 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Layout\DisplayString;
use Psy\Readline\Interactive\Terminal;
use Symfony\Component\Console\Formatter\OutputFormatter;
/**
* Renders history search results in a single-column list.
*
* Each item is prefixed with a prompt and the selected item is highlighted.
*/
class HistorySearchRenderer
{
private Terminal $terminal;
private string $prefix;
private string $query = '';
public function __construct(Terminal $terminal, string $prefix = '- ')
{
$this->terminal = $terminal;
$this->prefix = $prefix;
}
/**
* Set the search query for highlighting matches.
*/
public function setQuery(string $query): void
{
$this->query = $query;
}
/**
* Render history search items in a single-column list.
*
* @param string[] $items History matches (newest first)
* @param int $selectedIndex Index of selected item (-1 for none)
* @param int|null $maxRows Maximum visible rows (null for unlimited)
* @param int $scrollOffset First visible row index
* @param int $totalCount Total match count for status line
*
* @return string[] The rendered lines
*/
public function render(
array $items,
int $selectedIndex = 0,
?int $maxRows = null,
int $scrollOffset = 0,
int $totalCount = 0
): array {
if (empty($items)) {
return [$this->terminal->format(' <whisper>(no matches)</whisper>')];
}
$items = \array_map([History::class, 'collapseToSingleLine'], \array_values($items));
$prefixWidth = DisplayString::width($this->prefix);
$indent = \str_repeat(' ', $prefixWidth);
$maxWidth = \max(1, $this->terminal->getWidth() - $prefixWidth);
$totalRows = \count($items);
$needsTruncation = $maxRows !== null && $totalRows > $maxRows + 1;
$startRow = $needsTruncation ? $scrollOffset : 0;
$endRow = $needsTruncation
? \min($totalRows, $startRow + $maxRows)
: $totalRows;
$lines = [];
for ($row = $startRow; $row < $endRow; $row++) {
$item = DisplayString::truncate($items[$row], $maxWidth, true);
if ($row === $selectedIndex) {
$escaped = OutputFormatter::escape($item);
$itemWidth = DisplayString::width($item);
$padding = \max(0, $maxWidth - $itemWidth);
$lines[] = $this->terminal->format(
'<input_highlight>'.$this->prefix.$escaped.\str_repeat(' ', $padding).'</input_highlight>',
);
} else {
$lines[] = $indent.$this->highlightQuery($item);
}
}
if ($needsTruncation) {
$text = \sprintf('Items %d to %d of %d', $startRow + 1, $endRow, $totalCount > 0 ? $totalCount : $totalRows);
$lines[] = $this->terminal->format($indent.'<whisper>'.$text.'</whisper>');
}
return $lines;
}
/**
* Highlight the first occurrence of the search query in an item.
*/
private function highlightQuery(string $item): string
{
if ($this->query === '') {
return $item;
}
$caseSensitive = History::isSearchCaseSensitive($this->query);
$pos = $caseSensitive
? \mb_strpos($item, $this->query)
: \mb_stripos($item, $this->query);
if ($pos === false) {
return $item;
}
$before = \mb_substr($item, 0, $pos);
$match = \mb_substr($item, $pos, \mb_strlen($this->query));
$after = \mb_substr($item, $pos + \mb_strlen($this->query));
return $before.$this->terminal->format('<info>'.OutputFormatter::escape($match).'</info>').$after;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Helper;
/**
* Shared token analysis utilities.
*/
class TokenHelper
{
/** @var int[] Token types that indicate a statement continues on the next line */
private const TRAILING_TOKEN_OPS = [
\T_OBJECT_OPERATOR, \T_PAAMAYIM_NEKUDOTAYIM,
\T_BOOLEAN_AND, \T_BOOLEAN_OR, \T_LOGICAL_AND, \T_LOGICAL_OR,
\T_DOUBLE_ARROW, \T_COALESCE, \T_SPACESHIP,
];
/** @var string[] Single-character operators that indicate continuation */
private const TRAILING_CHAR_OPS = ['+', '-', '*', '/', '%', '.', '=', '&', '|', '^', '<', '>', ','];
/**
* Check whether the last non-whitespace token is a trailing operator.
*
* Used to determine whether a statement continues onto the next line.
*
* @param array $tokens token_get_all tokens
*/
public static function hasTrailingOperator(array $tokens): bool
{
if (empty($tokens)) {
return false;
}
$lastToken = null;
for ($i = \count($tokens) - 1; $i >= 0; $i--) {
$token = $tokens[$i];
if (\is_array($token) && $token[0] === \T_WHITESPACE) {
continue;
}
$lastToken = $token;
break;
}
if ($lastToken === null) {
return false;
}
if (\is_array($lastToken)) {
return \in_array($lastToken[0], self::TRAILING_TOKEN_OPS, true);
}
return \in_array($lastToken, self::TRAILING_CHAR_OPS, true);
}
}

View File

@@ -0,0 +1,433 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Helper\HistorySearchRenderer;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Input\Key;
use Psy\Readline\Interactive\Input\WordNavigationPolicy;
use Psy\Readline\Interactive\Renderer\FrameRenderer;
use Psy\Readline\Interactive\Renderer\OverlayViewport;
/**
* History search state machine for interactive readline.
*
* Manages the search query, match list, selection, viewport scrolling,
* and rendering for reverse history search (Ctrl-R).
*/
class HistorySearch
{
private Terminal $terminal;
private History $history;
private FrameRenderer $frameRenderer;
private OverlayViewport $overlayViewport;
private Theme $theme;
private bool $active = false;
private string $searchQuery = '';
/** @var string[] */
private array $searchMatches = [];
private int $currentMatchIndex = -1;
private int $searchScrollOffset = 0;
private bool $searchExpanded = false;
private ?string $savedBufferText = null;
private int $savedCursorPosition = 0;
private ?HistorySearchRenderer $renderer = null;
public function __construct(
Terminal $terminal,
History $history,
FrameRenderer $frameRenderer,
OverlayViewport $overlayViewport,
Theme $theme
) {
$this->terminal = $terminal;
$this->history = $history;
$this->frameRenderer = $frameRenderer;
$this->overlayViewport = $overlayViewport;
$this->theme = $theme;
}
/**
* Set the theme.
*/
public function setTheme(Theme $theme): void
{
$this->theme = $theme;
$this->renderer = null;
}
/**
* Check if search mode is active.
*/
public function isActive(): bool
{
return $this->active;
}
/**
* Enter search mode with an optional initial query.
*/
public function enter(string $query = ''): void
{
$this->active = true;
$this->updateQuery($query);
}
/**
* Exit search mode and clear state.
*/
public function exit(): void
{
$this->active = false;
$this->resetState();
$this->savedBufferText = null;
$this->savedCursorPosition = 0;
$this->frameRenderer->setOverlayLines([]);
}
/**
* Save the current buffer before starting search.
*/
public function saveBuffer(Buffer $buffer): void
{
$this->savedBufferText = $buffer->getText();
$this->savedCursorPosition = $buffer->getCursor();
}
/**
* Handle input while in search mode.
*
* @return bool|null True to stay in search, false if exited (key consumed), null if exited (replay key)
*/
public function handleInput(Key $key, Buffer $buffer)
{
$value = $key->getValue();
$keyStr = (string) $key;
if ($value === "\x07" || $value === "\x1b") { // Ctrl-G or Escape
$this->cancelSearch($buffer);
return false;
} elseif ($value === "\r" || $value === "\n") {
$this->acceptMatch($buffer);
return false;
} elseif ($keyStr === 'escape:[C' || $value === "\x06") { // Right or Ctrl-F: accept, cursor at end
$this->acceptMatch($buffer);
return false;
} elseif ($keyStr === 'escape:[D' || $value === "\x02") { // Left or Ctrl-B: accept, cursor at start
$this->acceptMatch($buffer);
$buffer->setCursor(0);
return false;
} elseif ($keyStr === 'escape:[A' || $value === "\x10" || $value === "\x13") { // Up, Ctrl-P, or Ctrl-S
$this->moveSelection(-1);
return true;
} elseif ($keyStr === 'escape:[B' || $value === "\x0e" || $value === "\x12") { // Down, Ctrl-N, or Ctrl-R
$this->moveSelection(1);
return true;
} elseif ($value === "\x17") { // Ctrl-W: delete last word
$this->removeWord();
return true;
} elseif ($value === "\x08" || $value === "\x7f") { // Backspace
$this->removeChar();
return true;
} elseif ($key->isChar() && !$key->isControl()) {
$this->addChar($value);
return true;
} else {
$this->acceptMatch($buffer);
return null;
}
}
/**
* Render the search UI with overlay.
*/
public function display(): void
{
$preview = $this->getSelectedMatch() ?? '';
$searchPrompt = $this->terminal->format('<whisper>search:</whisper>').' '.$this->searchQuery;
$this->updateOverlay();
$this->frameRenderer->renderSearchFrame($preview, $searchPrompt);
}
/**
* Get the search query.
*/
public function getQuery(): string
{
return $this->searchQuery;
}
/**
* Get the number of search matches.
*/
public function getMatchCount(): int
{
return \count($this->searchMatches);
}
/**
* Get the search selection index.
*/
public function getSelectedIndex(): int
{
return $this->currentMatchIndex;
}
/**
* Get the current search match.
*
* @return string|null The current match, or null if no match
*/
public function getSelectedMatch(): ?string
{
if (empty($this->searchMatches) || $this->currentMatchIndex < 0) {
return null;
}
return $this->searchMatches[$this->currentMatchIndex] ?? null;
}
/**
* Find next match in search results (older entries).
*/
public function findNext(): void
{
$this->moveSelection(1);
}
/**
* Find previous match in search results (newer entries).
*/
public function findPrevious(): void
{
$this->moveSelection(-1);
}
/**
* Update search query and find matches.
*/
public function updateQuery(string $query): void
{
$this->searchQuery = $query;
$this->searchMatches = $this->history->search($query);
$this->currentMatchIndex = empty($this->searchMatches) ? -1 : 0;
$this->searchScrollOffset = 0;
$this->searchExpanded = false;
}
/**
* Add a character to the search query.
*/
private function addChar(string $char): void
{
$this->updateQuery($this->searchQuery.$char);
}
/**
* Remove last character from search query.
*/
private function removeChar(): void
{
if ($this->searchQuery !== '') {
$this->updateQuery(\mb_substr($this->searchQuery, 0, -1));
}
}
/**
* Remove last word from search query.
*/
private function removeWord(): void
{
if ($this->searchQuery === '') {
return;
}
$policy = new WordNavigationPolicy();
$boundary = $policy->findPreviousWord($this->searchQuery, \mb_strlen($this->searchQuery));
$this->updateQuery(\mb_substr($this->searchQuery, 0, $boundary));
}
/**
* Reset search query and match state.
*/
private function resetState(): void
{
$this->searchQuery = '';
$this->searchMatches = [];
$this->currentMatchIndex = -1;
$this->searchScrollOffset = 0;
$this->searchExpanded = false;
}
/**
* Move search selection up or down, adjusting viewport as needed.
*/
private function moveSelection(int $delta): void
{
$count = \count($this->searchMatches);
if ($count === 0) {
$this->terminal->bell();
return;
}
$newIndex = $this->currentMatchIndex + $delta;
if ($newIndex < 0) {
$newIndex = $count - 1;
$this->terminal->bell();
} elseif ($newIndex >= $count) {
$newIndex = 0;
$this->terminal->bell();
}
$this->currentMatchIndex = $newIndex;
$this->adjustViewport();
}
/**
* Accept current search match and exit search mode.
*/
private function acceptMatch(Buffer $buffer): void
{
$match = $this->getSelectedMatch();
if ($match !== null) {
$buffer->clear();
$buffer->insert($match);
}
$this->exit();
}
/**
* Cancel search and restore original buffer.
*/
private function cancelSearch(Buffer $buffer): void
{
if ($this->savedBufferText !== null) {
$buffer->clear();
$buffer->insert($this->savedBufferText);
$buffer->setCursor($this->savedCursorPosition);
}
$this->exit();
}
/**
* Build and set overlay lines for the current search results.
*/
private function updateOverlay(): void
{
if (empty($this->searchMatches)) {
if ($this->searchQuery !== '') {
$noMatch = $this->terminal->format(' <whisper>(no matches)</whisper>');
$this->frameRenderer->setOverlayLines([$noMatch]);
} else {
$this->frameRenderer->setOverlayLines([]);
}
return;
}
if ($this->renderer === null) {
$this->renderer = new HistorySearchRenderer($this->terminal, $this->theme->replayPrompt());
}
$this->renderer->setQuery($this->searchQuery);
$maxRows = $this->getMaxRows();
$lines = $this->renderer->render(
$this->searchMatches,
$this->currentMatchIndex,
$maxRows,
$this->searchScrollOffset,
\count($this->searchMatches),
);
$this->frameRenderer->setOverlayLines($lines);
}
/**
* Get the maximum visible rows for search results.
*
* Returns null if all rows fit without truncation.
*/
private function getMaxRows(): ?int
{
$available = $this->overlayViewport->getAvailableRows(!$this->searchExpanded);
// OverlayViewport already accounts for input frame + search prompt rows.
$menuBudget = \max(1, $available);
$totalRows = \count($this->searchMatches);
if ($totalRows <= $menuBudget) {
return null;
}
// Reserve one row for the status line
return \max(1, $menuBudget - 1);
}
/**
* Adjust search scroll offset so the selected item is visible.
*/
private function adjustViewport(): void
{
$maxRows = $this->getMaxRows();
if ($maxRows === null) {
$this->searchScrollOffset = 0;
return;
}
$selected = $this->currentMatchIndex;
// Scrolled past the top: snap to selection
if ($selected < $this->searchScrollOffset) {
$this->searchScrollOffset = $selected;
}
// Scrolled past the bottom: try expanding the viewport first
if ($selected >= $this->searchScrollOffset + $maxRows && !$this->searchExpanded) {
$this->searchExpanded = true;
$maxRows = $this->getMaxRows();
if ($maxRows === null) {
$this->searchScrollOffset = 0;
return;
}
}
// Still past the bottom after expanding: scroll down
if ($selected >= $this->searchScrollOffset + $maxRows) {
$this->searchScrollOffset = $selected - $maxRows + 1;
}
$count = \count($this->searchMatches);
$this->searchScrollOffset = \max(0, \min($this->searchScrollOffset, $count - $maxRows));
}
}

View File

@@ -0,0 +1,883 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\CodeAnalysis\BufferAnalysis;
use Psy\CodeAnalysis\BufferAnalyzer;
use Psy\Readline\Interactive\Helper\BracketPair;
/**
* Manages the text buffer (the text being edited).
*
* Includes PHP-awareness through PHP-Parser integration for understanding
* syntax, detecting complete statements, and providing context-aware features.
*/
class Buffer
{
private string $text = '';
private int $cursor = 0;
private BufferAnalyzer $bufferAnalyzer;
private StatementCompletenessPolicy $statementCompletenessPolicy;
private IndentationPolicy $indentationPolicy;
private TokenNavigationPolicy $tokenNavigationPolicy;
private WordNavigationPolicy $wordNavigationPolicy;
private VisualNavigationPolicy $visualNavigationPolicy;
private bool $requireSemicolons = false;
private bool $graphemeCacheInitialized = false;
/** @var int[]|null */
private ?array $graphemeBoundaries = null;
/** @var array<int,int>|null */
private ?array $graphemeBoundaryMap = null;
public function __construct(bool $requireSemicolons = false)
{
$this->requireSemicolons = $requireSemicolons;
$this->bufferAnalyzer = new BufferAnalyzer();
$this->statementCompletenessPolicy = new StatementCompletenessPolicy(
$this->bufferAnalyzer,
$this->requireSemicolons
);
$this->indentationPolicy = new IndentationPolicy();
$this->tokenNavigationPolicy = new TokenNavigationPolicy();
$this->wordNavigationPolicy = new WordNavigationPolicy();
$this->visualNavigationPolicy = new VisualNavigationPolicy();
}
/**
* Get the buffer text.
*/
public function getText(): string
{
return $this->text;
}
/**
* Set the buffer text and move cursor to end.
*/
public function setText(string $text): void
{
$this->text = $text;
$this->cursor = \mb_strlen($text);
$this->invalidateCaches();
}
/**
* Clear the buffer.
*/
public function clear(): void
{
$this->text = '';
$this->cursor = 0;
$this->invalidateCaches();
}
/**
* Get the cursor position.
*/
public function getCursor(): int
{
return $this->cursor;
}
/**
* Set the cursor position.
*/
public function setCursor(int $position): void
{
$this->cursor = \max(0, \min($position, \mb_strlen($this->text)));
}
/**
* Get the length of the text in characters.
*/
public function getLength(): int
{
return \mb_strlen($this->text);
}
/**
* Insert text at the cursor position.
*/
public function insert(string $text): void
{
$before = \mb_substr($this->text, 0, $this->cursor);
$after = \mb_substr($this->text, $this->cursor);
$this->text = $before.$text.$after;
$this->cursor += \mb_strlen($text);
$this->invalidateCaches();
}
/**
* Auto-dedent if typing a closing bracket at start of line.
*
* If the line currently starts with auto-inserted whitespace and
* we're about to insert a closing bracket, remove one indent level.
*
* @param string $char The character being typed
* @param string $context Optional accumulated code context for bracket matching
*/
public function autoDedentIfClosingBracket(string $char, string $context = ''): void
{
$spacesToRemove = $this->indentationPolicy->calculateClosingBracketDedent(
$char, $this->text, $this->cursor, $context
);
if ($spacesToRemove > 0) {
$lines = \explode("\n", $this->text);
$currentLineNum = $this->getCurrentLineNumber();
$lines[$currentLineNum] = \substr($lines[$currentLineNum], $spacesToRemove);
$this->text = \implode("\n", $lines);
$this->cursor -= $spacesToRemove;
$this->invalidateCaches();
}
}
/**
* Delete grapheme clusters backward from cursor.
*
* @return bool True if characters were deleted
*/
public function deleteBackward(int $count = 1): bool
{
if ($this->cursor === 0 || $count <= 0) {
return false;
}
$span = $this->graphemeClusterSpanReverse($count);
$before = \mb_substr($this->text, 0, $this->cursor - $span);
$after = \mb_substr($this->text, $this->cursor);
$this->text = $before.$after;
$this->cursor -= $span;
$this->invalidateCaches();
return true;
}
/**
* Delete grapheme clusters forward from cursor.
*
* @return bool True if characters were deleted
*/
public function deleteForward(int $count = 1): bool
{
if ($this->cursor >= \mb_strlen($this->text) || $count <= 0) {
return false;
}
$span = $this->graphemeClusterSpanForward($count);
$before = \mb_substr($this->text, 0, $this->cursor);
$after = \mb_substr($this->text, $this->cursor + $span);
$this->text = $before.$after;
$this->invalidateCaches();
return true;
}
/**
* Delete from cursor to end of line.
*
* @return string The deleted text
*/
public function deleteToEnd(): string
{
$nextNewline = \mb_strpos($this->text, "\n", $this->cursor);
$end = $nextNewline === false ? \mb_strlen($this->text) : $nextNewline;
$killed = \mb_substr($this->text, $this->cursor, $end - $this->cursor);
$this->text = \mb_substr($this->text, 0, $this->cursor).\mb_substr($this->text, $end);
$this->invalidateCaches();
return $killed;
}
/**
* Delete from start of line to cursor.
*
* @return string The deleted text
*/
public function deleteToStart(): string
{
$beforeCursor = \mb_substr($this->text, 0, $this->cursor);
$lastNewline = \mb_strrpos($beforeCursor, "\n");
$start = $lastNewline === false ? 0 : $lastNewline + 1;
$killed = \mb_substr($this->text, $start, $this->cursor - $start);
$this->text = \mb_substr($this->text, 0, $start).\mb_substr($this->text, $this->cursor);
$this->cursor = $start;
$this->invalidateCaches();
return $killed;
}
/**
* Move cursor left by specified number of grapheme clusters.
*
* @return int Number of code points actually moved
*/
public function moveCursorLeft(int $count = 1): int
{
$oldCursor = $this->cursor;
$span = $this->graphemeClusterSpanReverse($count);
$this->cursor = \max(0, $this->cursor - $span);
return $oldCursor - $this->cursor;
}
/**
* Move cursor right by specified number of grapheme clusters.
*
* @return int Number of code points actually moved
*/
public function moveCursorRight(int $count = 1): int
{
$oldCursor = $this->cursor;
$span = $this->graphemeClusterSpanForward($count);
$this->cursor = \min(\mb_strlen($this->text), $this->cursor + $span);
return $this->cursor - $oldCursor;
}
/**
* Get the grapheme cluster before the cursor.
*
* @return string|null The grapheme cluster, or null if at start
*/
public function getCharBeforeCursor(): ?string
{
if ($this->cursor === 0) {
return null;
}
$span = $this->graphemeClusterSpanReverse(1);
return \mb_substr($this->text, $this->cursor - $span, $span);
}
/**
* Get the grapheme cluster after the cursor.
*
* @return string|null The grapheme cluster, or null if at end
*/
public function getCharAfterCursor(): ?string
{
if ($this->cursor >= \mb_strlen($this->text)) {
return null;
}
$span = $this->graphemeClusterSpanForward(1);
return \mb_substr($this->text, $this->cursor, $span);
}
/**
* Move cursor to start of line.
*/
public function moveCursorToStart(): void
{
$this->cursor = 0;
}
/**
* Move cursor to end of line.
*/
public function moveCursorToEnd(): void
{
$this->cursor = \mb_strlen($this->text);
}
/**
* Move cursor to start of current line (for multi-line buffers).
*/
public function moveCursorToStartOfCurrentLine(): void
{
$beforeCursor = \mb_substr($this->text, 0, $this->cursor);
$lastNewline = \mb_strrpos($beforeCursor, "\n");
$this->cursor = $lastNewline === false ? 0 : $lastNewline + 1;
}
/**
* Move cursor to end of current line (for multi-line buffers).
*/
public function moveCursorToEndOfCurrentLine(): void
{
$nextNewline = \mb_strpos($this->text, "\n", $this->cursor);
$this->cursor = $nextNewline === false ? \mb_strlen($this->text) : $nextNewline;
}
/**
* Get the current line number (0-indexed) based on cursor position.
*/
public function getCurrentLineNumber(): int
{
return \substr_count(\mb_substr($this->text, 0, $this->cursor), "\n");
}
/**
* Get the text of the current line (where cursor is).
*/
public function getCurrentLineText(): string
{
$lines = \explode("\n", $this->text);
$lineNumber = $this->getCurrentLineNumber();
return $lines[$lineNumber] ?? '';
}
/**
* Get the cursor position within the current line (0-indexed).
*/
public function getCursorPositionInLine(): int
{
$lineNumber = $this->getCurrentLineNumber();
$lines = \explode("\n", $this->text);
$charsBeforeLine = 0;
for ($i = 0; $i < $lineNumber; $i++) {
$charsBeforeLine += \mb_strlen($lines[$i]) + 1;
}
return $this->cursor - $charsBeforeLine;
}
/**
* Get the total number of lines in the buffer.
*/
public function getLineCount(): int
{
return \substr_count($this->text, "\n") + 1;
}
/**
* Check if the cursor is on the first line.
*/
public function isOnFirstLine(): bool
{
return $this->getCurrentLineNumber() === 0;
}
/**
* Check if the cursor is on the last line.
*/
public function isOnLastLine(): bool
{
return $this->getCurrentLineNumber() === $this->getLineCount() - 1;
}
/**
* Move cursor to the previous line, maintaining column position if possible.
*
* @return bool True if moved, false if already on first line
*/
public function moveToPreviousLine(): bool
{
if ($this->isOnFirstLine()) {
return false;
}
$beforeCursor = \mb_substr($this->text, 0, $this->cursor);
$lastNewline = \mb_strrpos($beforeCursor, "\n");
if ($lastNewline === false) {
return false;
}
$currentColumn = $this->cursor - $lastNewline - 1;
$beforeCurrentLine = \mb_substr($this->text, 0, $lastNewline);
$prevLineStart = \mb_strrpos($beforeCurrentLine, "\n");
$prevLineStart = $prevLineStart === false ? 0 : $prevLineStart + 1;
$prevLineLength = $lastNewline - $prevLineStart;
$this->cursor = $prevLineStart + \min($currentColumn, $prevLineLength);
return true;
}
/**
* Move cursor to the previous soft-wrapped visual row on the current line.
*
* @param int $terminalWidth Number of terminal columns
* @param int $promptWidth Display width of the active prompt
*
* @return bool True if moved, false if already on first visual row
*/
public function moveToPreviousVisualRow(int $terminalWidth, int $promptWidth): bool
{
return $this->moveVisualRows(-1, $terminalWidth, $promptWidth);
}
/**
* Move cursor to the next soft-wrapped visual row on the current line.
*
* @param int $terminalWidth Number of terminal columns
* @param int $promptWidth Display width of the active prompt
*
* @return bool True if moved, false if already on last visual row
*/
public function moveToNextVisualRow(int $terminalWidth, int $promptWidth): bool
{
return $this->moveVisualRows(1, $terminalWidth, $promptWidth);
}
/**
* Move cursor by soft-wrapped visual rows.
*/
private function moveVisualRows(int $deltaRows, int $terminalWidth, int $promptWidth): bool
{
$target = $this->visualNavigationPolicy->moveByRows(
$this->text,
$this->cursor,
$deltaRows,
$terminalWidth,
$promptWidth
);
if ($target === null) {
return false;
}
$this->cursor = $target;
return true;
}
/**
* Move cursor to the next line, maintaining column position if possible.
*
* @return bool True if moved, false if already on last line
*/
public function moveToNextLine(): bool
{
if ($this->isOnLastLine()) {
return false;
}
$beforeCursor = \mb_substr($this->text, 0, $this->cursor);
$lastNewline = \mb_strrpos($beforeCursor, "\n");
$currentLineStart = $lastNewline === false ? 0 : $lastNewline + 1;
$currentColumn = $this->cursor - $currentLineStart;
$nextNewline = \mb_strpos($this->text, "\n", $this->cursor);
if ($nextNewline === false) {
return false;
}
$nextLineStart = $nextNewline + 1;
$lineAfterNext = \mb_strpos($this->text, "\n", $nextLineStart);
$nextLineEnd = $lineAfterNext === false ? \mb_strlen($this->text) : $lineAfterNext;
$nextLineLength = $nextLineEnd - $nextLineStart;
$this->cursor = $nextLineStart + \min($currentColumn, $nextLineLength);
return true;
}
/**
* Get text before cursor.
*/
public function getBeforeCursor(): string
{
return \mb_substr($this->text, 0, $this->cursor);
}
/**
* Get text after cursor.
*/
public function getAfterCursor(): string
{
return \mb_substr($this->text, $this->cursor);
}
/**
* Find the position of the start of the previous word.
*/
public function findPreviousWord(): int
{
return $this->wordNavigationPolicy->findPreviousWord($this->text, $this->cursor);
}
/**
* Find the position of the start of the next word.
*/
public function findNextWord(): int
{
return $this->wordNavigationPolicy->findNextWord($this->text, $this->cursor);
}
/**
* Delete previous word (from cursor backwards).
*
* @return string The deleted text
*/
public function deletePreviousWord(): string
{
return $this->deleteBackwardTo(fn () => $this->findPreviousWord());
}
/**
* Delete next word (from cursor forwards).
*
* @return string The deleted text
*/
public function deleteNextWord(): string
{
return $this->deleteForwardTo($this->findNextWord());
}
/**
* Check if the buffer is empty.
*/
public function isEmpty(): bool
{
return $this->text === '';
}
/**
* Get the code-point span of N grapheme clusters forward from cursor.
*
* Uses PCRE \X to match extended grapheme clusters (Unicode TR#29).
*/
private function graphemeClusterSpanForward(int $count): int
{
if ($count <= 0) {
return 0;
}
if ($this->initializeGraphemeCache() && $this->graphemeBoundaries !== null && isset($this->graphemeBoundaryMap[$this->cursor])) {
$startIndex = $this->graphemeBoundaryMap[$this->cursor];
$targetIndex = \min($startIndex + $count, \count($this->graphemeBoundaries) - 1);
return $this->graphemeBoundaries[$targetIndex] - $this->cursor;
}
return $this->graphemeClusterSpan(\mb_substr($this->text, $this->cursor), $count, false);
}
/**
* Get the code-point span of N grapheme clusters backward from cursor.
*/
private function graphemeClusterSpanReverse(int $count): int
{
if ($count <= 0) {
return 0;
}
if ($this->initializeGraphemeCache() && $this->graphemeBoundaries !== null && isset($this->graphemeBoundaryMap[$this->cursor])) {
$startIndex = $this->graphemeBoundaryMap[$this->cursor];
$targetIndex = \max(0, $startIndex - $count);
return $this->cursor - $this->graphemeBoundaries[$targetIndex];
}
return $this->graphemeClusterSpan(\mb_substr($this->text, 0, $this->cursor), $count, true);
}
/**
* Build grapheme boundaries for current text.
*/
private function initializeGraphemeCache(): bool
{
if ($this->graphemeCacheInitialized) {
return $this->graphemeBoundaries !== null;
}
$this->graphemeCacheInitialized = true;
if ($this->text === '') {
$this->graphemeBoundaries = [0];
$this->graphemeBoundaryMap = [0 => 0];
return true;
}
if (\preg_match_all('/\X/u', $this->text, $matches) === false || empty($matches[0])) {
$this->graphemeBoundaries = null;
$this->graphemeBoundaryMap = null;
return false;
}
$boundaries = [0];
$boundaryMap = [0 => 0];
$offset = 0;
$index = 0;
foreach ($matches[0] as $cluster) {
$offset += \mb_strlen($cluster);
$boundaries[] = $offset;
$boundaryMap[$offset] = ++$index;
}
$this->graphemeBoundaries = $boundaries;
$this->graphemeBoundaryMap = $boundaryMap;
return true;
}
/**
* Get the code-point span of N grapheme clusters from one end of a string.
*
* @param string $text The text to measure
* @param int $count Number of grapheme clusters
* @param bool $fromEnd Take clusters from the end rather than the start
*/
private function graphemeClusterSpan(string $text, int $count, bool $fromEnd): int
{
if ($count <= 0 || $text === '') {
return 0;
}
// Fall back to code-point count if regex fails (e.g. invalid UTF-8)
if (\preg_match_all('/\X/u', $text, $matches) === false || empty($matches[0])) {
return \min($count, \mb_strlen($text));
}
$offset = $fromEnd ? -$count : 0;
$clusters = \array_slice($matches[0], $offset, $count);
return \array_sum(\array_map('mb_strlen', $clusters));
}
/**
* Delete backward from cursor to a target position, including bracket pairs when applicable.
*
* @param callable $findTarget Returns the target position when called with current cursor state
*
* @return string The deleted text
*/
private function deleteBackwardTo(callable $findTarget): string
{
if (BracketPair::isInsideEmptyBrackets($this)) {
// Include the bracket pair in the deletion
$savedCursor = $this->cursor;
$this->cursor--;
$targetStart = $findTarget();
$this->cursor = $savedCursor;
$killed = \mb_substr($this->text, $targetStart, $this->cursor - $targetStart + 1);
$before = \mb_substr($this->text, 0, $targetStart);
$after = \mb_substr($this->text, $this->cursor + 1);
$this->text = $before.$after;
$this->cursor = $targetStart;
$this->invalidateCaches();
return $killed;
}
$targetStart = $findTarget();
$killed = \mb_substr($this->text, $targetStart, $this->cursor - $targetStart);
$before = \mb_substr($this->text, 0, $targetStart);
$after = \mb_substr($this->text, $this->cursor);
$this->text = $before.$after;
$this->cursor = $targetStart;
$this->invalidateCaches();
return $killed;
}
/**
* Delete forward from cursor to a target position.
*
* @return string The deleted text
*/
private function deleteForwardTo(int $targetEnd): string
{
$killed = \mb_substr($this->text, $this->cursor, $targetEnd - $this->cursor);
$before = \mb_substr($this->text, 0, $this->cursor);
$after = \mb_substr($this->text, $targetEnd);
$this->text = $before.$after;
$this->invalidateCaches();
return $killed;
}
private function invalidateCaches(): void
{
$this->graphemeCacheInitialized = false;
$this->graphemeBoundaries = null;
$this->graphemeBoundaryMap = null;
}
private function getBufferAnalysis(): BufferAnalysis
{
return $this->bufferAnalyzer->analyze($this->getText());
}
/**
* @return array Raw token_get_all() tokens
*/
private function getParsedTokens(): array
{
return $this->getBufferAnalysis()->getTokens();
}
/**
* @return array<int, array{start: int, end: int}>
*/
private function getParsedTokenPositions(): array
{
return $this->getBufferAnalysis()->getTokenPositions();
}
/**
* Check if the buffer contains a complete PHP statement.
*/
public function isCompleteStatement(): bool
{
return $this->statementCompletenessPolicy->isCompleteStatement($this->text);
}
/**
* Check if the buffer has an unrecoverable syntax error.
*/
public function hasUnrecoverableSyntaxError(): bool
{
return $this->statementCompletenessPolicy->hasUnrecoverableSyntaxError($this->text);
}
/**
* Check whether text before the cursor has unclosed brackets.
*/
public function hasUnclosedBracketsBeforeCursor(): bool
{
$textBeforeCursor = $this->getBeforeCursor();
if (\trim($textBeforeCursor) === '') {
return false;
}
return !$this->statementCompletenessPolicy->hasBalancedBrackets($textBeforeCursor);
}
/**
* Calculate the appropriate indentation for the next line.
*
* Analyzes the current line to determine how much whitespace should
* be automatically inserted when continuing to a new line.
*
* @return string The whitespace to insert (spaces or tabs)
*/
public function calculateNextLineIndent(): string
{
return $this->indentationPolicy->calculateNextLineIndent(
$this->text,
$this->getParsedTokens()
);
}
/**
* Calculate indentation using only text before the cursor.
*/
public function calculateIndentBeforeCursor(): string
{
$textBeforeCursor = $this->getBeforeCursor();
$tokens = $this->bufferAnalyzer->analyze($textBeforeCursor)->getTokens();
return $this->indentationPolicy->calculateNextLineIndent($textBeforeCursor, $tokens);
}
/**
* Remove one level of indentation from an indent string.
*/
public function dedent(string $indent): string
{
return $this->indentationPolicy->dedent($indent);
}
/**
* Get the number of spaces needed to reach the next tab stop.
*/
public function spacesToNextTabStop(int $column): int
{
return $this->indentationPolicy->spacesToNextTabStop($column);
}
/**
* Get the number of spaces to remove to reach the previous tab stop.
*/
public function spacesToPreviousTabStop(int $spaces): int
{
return $this->indentationPolicy->spacesToPreviousTabStop($spaces);
}
/**
* Find the start position of the previous token.
*
* Navigates to the start of the token before the cursor position.
* If cursor is inside a token (not at start), goes to start of that token.
* If cursor is at the start of a token, goes to previous token.
*
* @return int Position of previous token start, or 0 if at beginning
*/
public function findPreviousToken(): int
{
return $this->tokenNavigationPolicy->findPreviousToken(
$this->getParsedTokens(),
$this->getParsedTokenPositions(),
$this->cursor
);
}
/**
* Find the start position of the next token.
*
* Navigates to the start of the token after the cursor position.
* If cursor is inside a token, goes to next token after that.
* If cursor is between tokens, goes to next token.
*
* @return int Position of next token start, or end of line if at end
*/
public function findNextToken(): int
{
return $this->tokenNavigationPolicy->findNextToken(
$this->getParsedTokens(),
$this->getParsedTokenPositions(),
$this->cursor,
$this->getLength()
);
}
/**
* Delete from cursor backwards to start of previous token.
*
* @return string The deleted text
*/
public function deletePreviousToken(): string
{
return $this->deleteBackwardTo(fn () => $this->findPreviousToken());
}
/**
* Delete from cursor forwards to start of next token.
*
* @return string The deleted text
*/
public function deleteNextToken(): string
{
return $this->deleteForwardTo($this->findNextToken());
}
}

View File

@@ -0,0 +1,575 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\Util\Str;
/**
* Command history manager.
*
* Manages a list of previously entered commands with navigation support.
*/
class History
{
private const HISTORY_SIGNATURE = [
'type' => 'psysh-history',
'format' => 'jsonl',
'version' => 1,
];
/** @var array[] History entries (index 0 = oldest, last index = most recent) */
private array $entries = [];
/** @var int Current position in history (-1 = not in history, index entries in when navigating) */
private int $position = -1;
/** Temporary entry saved when entering history, restored on forward exit */
private ?string $temporaryEntry = null;
/** Search term for filtered history navigation (null = no filtering) */
private ?string $searchTerm = null;
/** Last command returned during navigation, for skipping consecutive dupes */
private ?string $lastNavigatedCommand = null;
private int $maxSize;
private bool $eraseDups;
private int $revision = 0;
public function __construct(int $maxSize = 10000, bool $eraseDups = false)
{
$this->maxSize = $maxSize;
$this->eraseDups = $eraseDups;
}
/**
* Add an entry to history.
*
* Skips empty lines and duplicates of the most recent entry.
* If eraseDups is enabled, removes any previous occurrence of the same command.
*/
public function add(string $line): void
{
if (\trim($line) === '') {
return;
}
$lastIndex = \count($this->entries) - 1;
if ($lastIndex >= 0 && $this->entries[$lastIndex]['command'] === $line) {
return;
}
if ($this->eraseDups) {
$this->entries = \array_values(\array_filter($this->entries, fn ($entry) => $entry['command'] !== $line));
}
$this->entries[] = [
'command' => $line,
'timestamp' => \time(),
'lines' => \substr_count($line, "\n") + 1,
];
if ($this->maxSize > 0 && \count($this->entries) > $this->maxSize) {
$this->entries = \array_slice($this->entries, -$this->maxSize);
}
$this->revision++;
}
/**
* Get the previous history entry.
*
* Moves backward in history (toward older entries).
* When a search term is set, skips entries that don't match.
*
* @return string|null The previous entry, or null if at the beginning
*/
public function getPrevious(): ?string
{
if (empty($this->entries)) {
return null;
}
$start = $this->position === -1 ? \count($this->entries) - 1 : $this->position - 1;
for ($i = $start; $i >= 0; $i--) {
$command = $this->entries[$i]['command'];
if ($this->matchesSearchTerm($command) && $command !== $this->lastNavigatedCommand) {
$this->position = $i;
$this->lastNavigatedCommand = $command;
return $command;
}
}
return null;
}
/**
* Get the next history entry.
*
* Moves forward in history (toward newer entries).
* When a search term is set, skips entries that don't match.
* Restores the temporary entry when moving past the most recent match.
*
* @return string|null The next entry, or the saved input when exiting history
*/
public function getNext(): ?string
{
if ($this->position === -1) {
return null;
}
$lastIndex = \count($this->entries) - 1;
for ($i = $this->position + 1; $i <= $lastIndex; $i++) {
$command = $this->entries[$i]['command'];
if ($this->matchesSearchTerm($command) && $command !== $this->lastNavigatedCommand) {
$this->position = $i;
$this->lastNavigatedCommand = $command;
return $command;
}
}
// No more matches forward — exit history, restore original input
$this->position = -1;
$this->lastNavigatedCommand = null;
$temp = $this->temporaryEntry;
$this->temporaryEntry = null;
return $temp ?? '';
}
/**
* Reset history navigation.
*
* Call this when a line is accepted to prepare for next input.
*/
public function reset(): void
{
$this->position = -1;
$this->temporaryEntry = null;
$this->searchTerm = null;
$this->lastNavigatedCommand = null;
}
/**
* Save the current input as temporary entry for restoring when exiting history forward.
*/
public function saveTemporaryEntry(string $entry): void
{
$this->temporaryEntry = $entry;
}
/**
* Set the search term for filtered history navigation.
*
* When set, getPrevious() and getNext() will only return entries
* containing the search term as a substring.
*
* Uses smart case: case-insensitive unless the term contains uppercase.
*/
public function setSearchTerm(?string $term): void
{
$this->searchTerm = ($term !== null && $term !== '') ? $term : null;
}
/**
* Get the current search term for filtered navigation.
*/
public function getSearchTerm(): ?string
{
return $this->searchTerm;
}
/**
* Check if currently navigating history.
*/
public function isInHistory(): bool
{
return $this->position !== -1;
}
/**
* Get the current position in history.
*/
public function getPosition(): int
{
return $this->position;
}
/**
* Get all history entries.
*
* @return array[] History entries (index 0 = oldest, last index = most recent)
*/
public function getAll(): array
{
return $this->entries;
}
/**
* Search history for entries matching a query.
*
* @param string $query The search query (smart-case: case-sensitive if query contains uppercase)
* @param bool $reverse If true, return oldest matches first; if false, newest first
*
* @return string[] Matching command strings
*/
public function search(string $query, bool $reverse = false): array
{
if ($query === '') {
$commands = $this->deduplicateCommands(\array_column($this->entries, 'command'));
return $reverse ? $commands : \array_reverse($commands);
}
$caseSensitive = self::isSearchCaseSensitive($query);
$matches = [];
foreach ($this->entries as $entry) {
$found = $caseSensitive
? \strpos($entry['command'], $query) !== false
: \stripos($entry['command'], $query) !== false;
if ($found) {
$matches[] = $entry['command'];
}
}
$matches = $this->deduplicateCommands($matches);
return $reverse ? $matches : \array_reverse($matches);
}
/**
* Deduplicate commands, keeping only the last (most recent) occurrence.
*
* @param string[] $commands
*
* @return string[]
*/
private function deduplicateCommands(array $commands): array
{
$seen = [];
$result = [];
foreach (\array_reverse($commands) as $command) {
if (!isset($seen[$command])) {
$seen[$command] = true;
$result[] = $command;
}
}
return \array_reverse($result);
}
/**
* Get the number of entries in history.
*/
public function getCount(): int
{
return \count($this->entries);
}
/**
* Get the current history revision.
*
* Increments on every mutation (add, clear, load, import).
*/
public function getRevision(): int
{
return $this->revision;
}
/**
* Clear all history.
*/
public function clear(): void
{
$this->entries = [];
$this->position = -1;
$this->temporaryEntry = null;
$this->searchTerm = null;
$this->lastNavigatedCommand = null;
$this->revision++;
}
/**
* Load history from file.
*
* Loads JSONL (interactive history format) entries.
* Optionally accepts a JSON signature line at the top of the file.
* Non-JSON lines are ignored.
*/
public function loadFromFile(string $path): void
{
$lines = $this->readLinesFromFile($path);
if ($lines === null) {
return;
}
$hasJsonSignature = false;
$entries = [];
foreach ($lines as $i => $line) {
if ($i === 0 && self::isJsonHistorySignatureLine($line)) {
$hasJsonSignature = true;
continue;
}
$entry = $this->parseJsonLine($line);
if ($entry !== null) {
$entries[] = $entry;
}
}
// If the file has content but no valid JSONL entries, leave history unchanged.
if (!empty($lines) && empty($entries) && !$hasJsonSignature) {
return;
}
$this->setEntries($entries);
}
/**
* Import history from a legacy plain-text file.
*
* Legacy format stores one command per line.
*/
public function importFromFile(string $path): void
{
$lines = $this->readLinesFromFile($path);
if ($lines === null) {
return;
}
// libedit legacy files may start with a version signature line.
if (!empty($lines) && $lines[0] === '_HiStOrY_V2_') {
\array_shift($lines);
}
$entries = [];
foreach ($lines as $line) {
$entry = $this->parseLegacyLine($line);
if ($entry !== null) {
$entries[] = $entry;
}
}
$this->setEntries($entries);
}
/**
* Save history to file.
*
* Saves in JSONL format with metadata.
*/
public function saveToFile(string $path): void
{
$dir = \dirname($path);
if (!\is_dir($dir)) {
@\mkdir($dir, 0700, true);
}
$jsonFlags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
$lines = \array_map(fn ($entry) => \json_encode($entry, $jsonFlags), $this->entries);
\array_unshift($lines, \json_encode(self::HISTORY_SIGNATURE, $jsonFlags));
$content = \implode("\n", $lines)."\n";
@\file_put_contents($path, $content);
}
/**
* Check whether a line is valid JSON history (entry or signature).
*/
public static function isJsonHistoryLine(string $line): bool
{
$data = @\json_decode(\trim($line), true);
if (!\is_array($data)) {
return false;
}
if (isset($data['command']) && \is_string($data['command'])) {
return true;
}
return self::isJsonHistorySignatureData($data);
}
/**
* Get display command for history navigation.
*
* For multi-line commands, returns a single-line representation.
* Used by the `hist` command for display purposes.
*
* @param array $entry History entry
*/
public function getDisplayCommand(array $entry): string
{
$command = $entry['command'];
$lines = $entry['lines'] ?? 1;
if ($lines === 1) {
return $command;
}
return self::collapseToSingleLine($command);
}
/**
* Collapse multi-line text to a single line.
*/
public static function collapseToSingleLine(string $text): string
{
$lines = \explode("\n", $text);
$lines = \array_map('trim', $lines);
$lines = \array_filter($lines, fn ($line) => $line !== '');
return \implode(' ', $lines);
}
/**
* Parse a JSONL line into a history entry.
*
* @return array|null
*/
private function parseJsonLine(string $line): ?array
{
$data = @\json_decode(\trim($line), true);
if (!\is_array($data) || !isset($data['command']) || !\is_string($data['command'])) {
return null;
}
return [
'command' => $data['command'],
'timestamp' => $data['timestamp'] ?? \time(),
'lines' => $data['lines'] ?? (\substr_count($data['command'], "\n") + 1),
];
}
/**
* Parse a legacy plain-text history line into an entry.
*/
private function parseLegacyLine(string $line): ?array
{
// GNU/libedit history comments and timestamps use NUL-prefixed lines.
if ($line === '' || $line[0] === "\0") {
return null;
}
// Ignore comment/timestamp suffix after NUL.
if (($pos = \strpos($line, "\0")) !== false) {
$line = \substr($line, 0, $pos);
}
$command = Str::unvis(\rtrim($line, "\r"));
if ($command === '') {
return null;
}
return [
'command' => $command,
'timestamp' => \time(),
'lines' => \substr_count($command, "\n") + 1,
];
}
/**
* Check whether a line is a JSON history signature.
*/
private static function isJsonHistorySignatureLine(string $line): bool
{
$data = @\json_decode(\trim($line), true);
return \is_array($data) && self::isJsonHistorySignatureData($data);
}
/**
* Check whether decoded JSON matches the history signature schema.
*
* @param array $data
*/
private static function isJsonHistorySignatureData(array $data): bool
{
return ($data['type'] ?? null) === self::HISTORY_SIGNATURE['type']
&& ($data['format'] ?? null) === self::HISTORY_SIGNATURE['format']
&& \is_int($data['version'] ?? null)
&& $data['version'] === self::HISTORY_SIGNATURE['version'];
}
/**
* Read and normalize non-empty lines from a file.
*
* @return string[]|null
*/
private function readLinesFromFile(string $path): ?array
{
if (!\file_exists($path) || !\is_readable($path)) {
return null;
}
$content = @\file_get_contents($path);
if ($content === false) {
return null;
}
$lines = \explode("\n", $content);
return \array_values(\array_filter($lines, fn ($line) => \trim($line) !== ''));
}
/**
* Check whether a search term is case-sensitive (contains uppercase).
*/
public static function isSearchCaseSensitive(string $term): bool
{
return $term !== \mb_strtolower($term);
}
/**
* Check whether a command matches the current search term.
*
* Smart case: case-insensitive unless the search term contains uppercase.
*/
private function matchesSearchTerm(string $command): bool
{
if ($this->searchTerm === null) {
return true;
}
if (self::isSearchCaseSensitive($this->searchTerm)) {
return \strpos($command, $this->searchTerm) !== false;
}
return \stripos($command, $this->searchTerm) !== false;
}
/**
* Replace history entries, enforcing the configured max size.
*
* @param array $entries
*/
private function setEntries(array $entries): void
{
if ($this->maxSize > 0 && \count($entries) > $this->maxSize) {
$entries = \array_slice($entries, -$this->maxSize);
}
$this->entries = \array_values($entries);
$this->position = -1;
$this->temporaryEntry = null;
$this->searchTerm = null;
$this->lastNavigatedCommand = null;
$this->revision++;
}
}

View File

@@ -0,0 +1,247 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\Readline\Interactive\Helper\BracketPair;
/**
* Calculates indentation for multiline editing.
*/
class IndentationPolicy
{
private const INDENT_WIDTH = 4;
/**
* @param array $tokens Snapshot tokens for the current full buffer
*/
public function calculateNextLineIndent(string $bufferText, array $tokens): string
{
$lines = \explode("\n", $bufferText);
$lastLine = (string) \end($lines);
$trimmedLastLine = \trim($lastLine);
if ($trimmedLastLine === '') {
return '';
}
if ($this->isEndOfLineInStringOrComment($bufferText, $tokens)) {
return '';
}
$currentIndent = $this->getLineIndentation($lastLine);
$lastChar = \substr(\rtrim($trimmedLastLine), -1);
if (\in_array($lastChar, BracketPair::OPENING_BRACKETS)) {
return $this->indent($currentIndent);
}
if ($this->endsWithControlStructure($trimmedLastLine)) {
return $this->indent($currentIndent);
}
return $currentIndent;
}
/**
* Add one level of indentation.
*/
public function indent(string $currentIndent): string
{
return $currentIndent.\str_repeat(' ', self::INDENT_WIDTH);
}
/**
* Remove one level of indentation.
*/
public function dedent(string $indent): string
{
if ($indent === '') {
return '';
}
// Calculate the visual column width, accounting for tabs.
$column = 0;
$length = \strlen($indent);
for ($i = 0; $i < $length; $i++) {
$column += $indent[$i] === "\t" ? $this->spacesToNextTabStop($column) : 1;
}
$target = $column - $this->spacesToPreviousTabStop($column);
// Find the byte position where truncating leaves us at the target column.
$column = 0;
for ($i = 0; $i < $length; $i++) {
$column += $indent[$i] === "\t" ? $this->spacesToNextTabStop($column) : 1;
if ($column > $target) {
return \substr($indent, 0, $i);
}
}
return $indent;
}
/**
* Calculate how many spaces to remove when typing a closing bracket.
*
* Returns the number of leading spaces to strip from the current line
* when a closing bracket is typed at the end of a whitespace-only line.
*
* @param string $char The character being typed
* @param string $text The full buffer text
* @param int $cursor The current cursor position
* @param string $context Accumulated code context for bracket matching
*/
public function calculateClosingBracketDedent(string $char, string $text, int $cursor, string $context = ''): int
{
if (!\in_array($char, BracketPair::CLOSING_BRACKETS)) {
return 0;
}
$lines = \explode("\n", $text);
$currentLineNum = \substr_count(\mb_substr($text, 0, $cursor), "\n");
$currentLine = $lines[$currentLineNum];
$charsBeforeLine = 0;
for ($i = 0; $i < $currentLineNum; $i++) {
$charsBeforeLine += \mb_strlen($lines[$i]) + 1;
}
$cursorInLine = $cursor - $charsBeforeLine;
if (\trim($currentLine) !== '') {
return 0;
}
if ($cursorInLine !== \mb_strlen($currentLine)) {
return 0;
}
$leadingSpaces = \mb_strlen($currentLine) - \mb_strlen(\ltrim($currentLine));
if ($leadingSpaces === 0) {
return 0;
}
if ($context !== '' && !BracketPair::doesClosingBracketMatch($char, $context)) {
return 0;
}
return $this->spacesToPreviousTabStop($leadingSpaces);
}
/**
* @param array $tokens
*/
private function isEndOfLineInStringOrComment(string $bufferText, array $tokens): bool
{
if (empty($tokens)) {
return false;
}
$lastToken = null;
for ($i = \count($tokens) - 1; $i >= 0; $i--) {
$token = $tokens[$i];
if (\is_array($token) && $token[0] === \T_WHITESPACE) {
continue;
}
$lastToken = $token;
break;
}
if ($lastToken === null) {
return false;
}
if (\is_array($lastToken)) {
$tokenType = $lastToken[0];
if ($tokenType === \T_CONSTANT_ENCAPSED_STRING ||
$tokenType === \T_ENCAPSED_AND_WHITESPACE ||
$tokenType === \T_START_HEREDOC ||
$tokenType === \T_END_HEREDOC) {
return true;
}
if ($tokenType === \T_COMMENT) {
$text = \ltrim($lastToken[1]);
// Only suppress indentation for block comments, not single-line
return \strpos($text, '/*') === 0;
}
if ($tokenType === \T_DOC_COMMENT) {
return true;
}
if (\defined('T_BACKTICK') && $tokenType === T_BACKTICK) {
return true;
}
}
$trimmed = \rtrim($bufferText);
$lastChar = \substr($trimmed, -1);
if ($lastChar === '"' || $lastChar === "'" || $lastChar === '`') {
$doubleQuoteCount = \substr_count($bufferText, '"');
$singleQuoteCount = \substr_count($bufferText, "'");
$backtickCount = \substr_count($bufferText, '`');
if ($lastChar === '"' && $doubleQuoteCount % 2 === 1) {
return true;
}
if ($lastChar === "'" && $singleQuoteCount % 2 === 1) {
return true;
}
if ($lastChar === '`' && $backtickCount % 2 === 1) {
return true;
}
}
return false;
}
/**
* Get the number of spaces needed to reach the next tab stop.
*/
public function spacesToNextTabStop(int $column): int
{
return self::INDENT_WIDTH - ($column % self::INDENT_WIDTH);
}
/**
* Get the number of spaces to remove to reach the previous tab stop.
*/
public function spacesToPreviousTabStop(int $spaces): int
{
$remainder = $spaces % self::INDENT_WIDTH;
return $remainder === 0 ? self::INDENT_WIDTH : $remainder;
}
/**
* Extract leading whitespace from a line.
*/
private function getLineIndentation(string $line): string
{
if (\preg_match('/^(\s+)/', $line, $matches)) {
return $matches[1];
}
return '';
}
/**
* Check whether a line ends with a control structure (if, for, while, etc.).
*/
private function endsWithControlStructure(string $line): bool
{
return (bool) \preg_match('/\b(if|for|foreach|while|switch|else|elseif|do)\s*\([^)]*\)\s*$/', $line);
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\Readline\Interactive\Terminal;
/**
* Input event queue with pushback/replay support.
*/
class InputQueue
{
private Terminal $terminal;
/** @var Key[] */
private array $bufferedEvents = [];
public function __construct(Terminal $terminal)
{
$this->terminal = $terminal;
}
/**
* Read the next key event, replaying buffered events first.
*/
public function read(): Key
{
if (!empty($this->bufferedEvents)) {
return \array_shift($this->bufferedEvents);
}
return Key::normalized($this->terminal->readKey());
}
/**
* Push an event back so it is replayed on the next read.
*/
public function replay(Key $key): void
{
\array_unshift($this->bufferedEvents, Key::normalized($key));
}
}

View File

@@ -0,0 +1,130 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
/**
* Represents a key press.
*/
class Key
{
public const TYPE_CHAR = 'char';
public const TYPE_CONTROL = 'control';
public const TYPE_ESCAPE = 'escape';
public const TYPE_EOF = 'eof';
public const TYPE_PASTE = 'paste';
private string $value;
private string $type;
public function __construct(string $value, string $type)
{
$this->value = $value;
$this->type = $type;
}
/**
* Get the raw key value.
*/
public function getValue(): string
{
return $this->value;
}
/**
* Get the key type.
*/
public function getType(): string
{
return $this->type;
}
/**
* Check if this is a regular character key.
*/
public function isChar(): bool
{
return $this->type === self::TYPE_CHAR;
}
/**
* Check if this is a control character.
*/
public function isControl(): bool
{
return $this->type === self::TYPE_CONTROL;
}
/**
* Check if this is an escape sequence.
*/
public function isEscape(): bool
{
return $this->type === self::TYPE_ESCAPE;
}
/**
* Check if this is an EOF signal.
*/
public function isEof(): bool
{
return $this->type === self::TYPE_EOF;
}
/**
* Check if this is pasted content.
*/
public function isPaste(): bool
{
return $this->type === self::TYPE_PASTE;
}
/**
* Create a normalized copy of a key.
*/
public static function normalized(self $key): self
{
$value = $key->value;
$type = $key->type;
// Normalize CR to LF for deterministic line-accept handling.
if ($type === self::TYPE_CHAR && $value === "\r") {
$value = "\n";
}
// Normalize CSI-u key event type suffixes (e.g. \033[13;2:1u -> \033[13;2u).
if ($type === self::TYPE_ESCAPE && \preg_match('/^(\033\[\d+(?:;\d+)*):\d+u$/', $value, $matches)) {
$value = $matches[1].'u';
}
return new self($value, $type);
}
public function __toString(): string
{
if ($this->type === self::TYPE_CONTROL) {
$ord = \ord($this->value);
if ($ord < 32) {
$char = \chr($ord + 96);
return 'control:'.$char;
} elseif ($ord === 127) {
return 'control:?';
}
} elseif ($this->type === self::TYPE_ESCAPE) {
return 'escape:'.\substr($this->value, 1);
} elseif ($this->type === self::TYPE_CHAR) {
return 'char:'.$this->value;
}
return $this->type.':'.$this->value;
}
}

View File

@@ -0,0 +1,185 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\Readline\Interactive\Actions\AcceptSuggestionAction;
use Psy\Readline\Interactive\Actions\AcceptSuggestionWordAction;
use Psy\Readline\Interactive\Actions\ActionInterface;
use Psy\Readline\Interactive\Actions\ClearBufferAction;
use Psy\Readline\Interactive\Actions\ClearScreenAction;
use Psy\Readline\Interactive\Actions\DedentLeadingIndentationAction;
use Psy\Readline\Interactive\Actions\DeleteBackwardCharAction;
use Psy\Readline\Interactive\Actions\DeleteBracketPairAction;
use Psy\Readline\Interactive\Actions\DeleteForwardAction;
use Psy\Readline\Interactive\Actions\ExitIfEmptyAction;
use Psy\Readline\Interactive\Actions\FallbackAction;
use Psy\Readline\Interactive\Actions\InsertCloseBracketAction;
use Psy\Readline\Interactive\Actions\InsertLineBreakAction;
use Psy\Readline\Interactive\Actions\InsertLineBreakOnIncompleteStatementAction;
use Psy\Readline\Interactive\Actions\InsertLineBreakOnUnclosedBracketsAction;
use Psy\Readline\Interactive\Actions\InsertOpenBracketAction;
use Psy\Readline\Interactive\Actions\InsertQuoteAction;
use Psy\Readline\Interactive\Actions\KillLineAction;
use Psy\Readline\Interactive\Actions\KillWholeLineAction;
use Psy\Readline\Interactive\Actions\KillWordAction;
use Psy\Readline\Interactive\Actions\MoveLeftAction;
use Psy\Readline\Interactive\Actions\MoveRightAction;
use Psy\Readline\Interactive\Actions\MoveToEndAction;
use Psy\Readline\Interactive\Actions\MoveToStartAction;
use Psy\Readline\Interactive\Actions\MoveWordLeftAction;
use Psy\Readline\Interactive\Actions\MoveWordRightAction;
use Psy\Readline\Interactive\Actions\NextHistoryAction;
use Psy\Readline\Interactive\Actions\PreviousHistoryAction;
use Psy\Readline\Interactive\Actions\RejectSyntaxErrorAction;
use Psy\Readline\Interactive\Actions\ReverseSearchAction;
use Psy\Readline\Interactive\Actions\SubmitLineAction;
use Psy\Readline\Interactive\HistorySearch;
/**
* Keybindings registry, maps terminal key sequences to actions.
*/
class KeyBindings
{
/** @var ActionInterface[] Keyed by key pattern */
private array $bindings = [];
/**
* Bind a key to one or more actions.
*
* Multiple actions are executed as a fallback chain.
*
* @param string $keyPattern Key pattern (e.g., 'char:a', 'control:a', 'escape:[A')
*/
public function bind(string $keyPattern, ActionInterface $action, ActionInterface ...$fallbackActions): void
{
$this->bindings[$keyPattern] = empty($fallbackActions) ? $action : new FallbackAction([$action, ...$fallbackActions]);
}
/**
* Get the action bound to a key.
*/
public function get(Key $key): ?ActionInterface
{
return $this->bindings[(string) $key] ?? null;
}
/**
* Get all bindings.
*
* @return ActionInterface[] Keyed by key pattern
*/
public function getAll(): array
{
return $this->bindings;
}
/**
* Create default Emacs-style keybindings.
*
* @param bool $smartBrackets Enable smart bracket pairing
*/
public static function createDefault(History $history, HistorySearch $search, bool $smartBrackets = false): self
{
$bindings = new self();
// Enter/Return
$acceptLine = new FallbackAction([
new RejectSyntaxErrorAction(),
new InsertLineBreakOnUnclosedBracketsAction($smartBrackets),
new InsertLineBreakOnIncompleteStatementAction(),
new SubmitLineAction(),
], false);
$bindings->bind('char:'."\n", $acceptLine);
$bindings->bind('char:'."\r", $acceptLine);
// Shift+Enter variants (CSI-u / modifyOtherKeys)
$bindings->bind('escape:[13;2u', new InsertLineBreakAction());
$bindings->bind('escape:[13;2~', new InsertLineBreakAction());
$bindings->bind('escape:[27;2;13~', new InsertLineBreakAction());
// Shift+Enter remapped by some terminal setups (Esc+Enter)
$bindings->bind('escape:'."\r", new InsertLineBreakAction());
$bindings->bind('escape:'."\n", new InsertLineBreakAction());
// Backspace
$backspace = $smartBrackets
? new FallbackAction([new DedentLeadingIndentationAction(), new DeleteBracketPairAction(), new DeleteBackwardCharAction()])
: new FallbackAction([new DedentLeadingIndentationAction(), new DeleteBackwardCharAction()]);
$bindings->bind('control:h', $backspace);
$bindings->bind('control:?', $backspace);
// Delete
$bindings->bind('escape:[3~', new DeleteForwardAction());
// Arrow keys
$bindings->bind('escape:[D', new MoveLeftAction());
$bindings->bind('escape:[C', new AcceptSuggestionAction(), new MoveRightAction());
$bindings->bind('escape:[A', new PreviousHistoryAction($history));
$bindings->bind('escape:[B', new NextHistoryAction($history));
// Emacs cursor movement
$bindings->bind('control:b', new MoveLeftAction());
$bindings->bind('control:f', new AcceptSuggestionAction(), new MoveRightAction());
$bindings->bind('control:a', new MoveToStartAction());
$bindings->bind('control:e', new MoveToEndAction());
// Word movement - Alt+Arrow
$bindings->bind('escape:[1;3D', new MoveWordLeftAction());
$bindings->bind('escape:[1;3C', new AcceptSuggestionWordAction(), new MoveWordRightAction());
// Word movement - Ctrl+Arrow
$bindings->bind('escape:[1;5D', new MoveWordLeftAction());
$bindings->bind('escape:[1;5C', new MoveWordRightAction());
// Word movement - Emacs style
$bindings->bind('escape:b', new MoveWordLeftAction());
$bindings->bind('escape:f', new AcceptSuggestionWordAction(), new MoveWordRightAction());
// Home/End keys
$bindings->bind('escape:[H', new MoveToStartAction());
$bindings->bind('escape:[F', new MoveToEndAction());
$bindings->bind('escape:[1~', new MoveToStartAction());
$bindings->bind('escape:[4~', new MoveToEndAction());
// Kill operations
$bindings->bind('control:k', new KillLineAction());
$bindings->bind('control:u', new KillWholeLineAction());
$bindings->bind('control:w', new KillWordAction());
// Buffer control
$bindings->bind('control:c', new ClearBufferAction());
$bindings->bind('control:d', new ExitIfEmptyAction(), new DeleteForwardAction());
// History search
$bindings->bind('control:r', new ReverseSearchAction($search));
// Clear screen
$bindings->bind('control:l', new ClearScreenAction());
// Smart bracket pairing
if ($smartBrackets) {
// Opening brackets
$bindings->bind('char:(', new InsertOpenBracketAction('('));
$bindings->bind('char:[', new InsertOpenBracketAction('['));
$bindings->bind('char:{', new InsertOpenBracketAction('{'));
// Closing brackets
$bindings->bind('char:)', new InsertCloseBracketAction(')'));
$bindings->bind('char:]', new InsertCloseBracketAction(']'));
$bindings->bind('char:}', new InsertCloseBracketAction('}'));
// Quotes
$bindings->bind('char:"', new InsertQuoteAction('"'));
$bindings->bind("char:'", new InsertQuoteAction("'"));
}
return $bindings;
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use PhpParser\Error;
use Psy\CodeAnalysis\BufferAnalyzer;
/**
* Determines whether a buffer is ready to execute as a statement.
*/
class StatementCompletenessPolicy
{
private BufferAnalyzer $bufferAnalyzer;
private bool $requireSemicolons;
public function __construct(BufferAnalyzer $bufferAnalyzer, bool $requireSemicolons = false)
{
$this->bufferAnalyzer = $bufferAnalyzer;
$this->requireSemicolons = $requireSemicolons;
}
public function isCompleteStatement(string $line): bool
{
if ($line === '') {
return true;
}
$analysis = $this->bufferAnalyzer->analyze($line);
$lastError = $analysis->getLastError();
// Control structures like `if (...)` are incomplete in REPL context.
if ($analysis->hasControlStructureWithoutBody()) {
return false;
}
if ($lastError === null) {
if (!$analysis->hasBalancedBrackets()) {
return false;
}
if ($analysis->hasTrailingOperator()) {
return false;
}
return true;
}
if (!$this->requireSemicolons && $analysis->hasEOFError()) {
if ($this->bufferAnalyzer->canBeFixedWithSemicolon($line)) {
return true;
}
}
if ($this->isUnterminatedComment($lastError) || $this->isUnclosedString($lastError)) {
return false;
}
if (!$analysis->hasEOFError()) {
// Real syntax errors should execute immediately so the user sees them.
return true;
}
return false;
}
/**
* Check whether the buffer has a non-EOF parse error that isn't recoverable
* by adding more input (e.g. extra closing brackets, invalid syntax).
*/
public function hasUnrecoverableSyntaxError(string $line): bool
{
if ($line === '') {
return false;
}
$analysis = $this->bufferAnalyzer->analyze($line);
$lastError = $analysis->getLastError();
if ($lastError === null) {
return false;
}
if ($analysis->hasEOFError()) {
return false;
}
if ($this->isUnterminatedComment($lastError) || $this->isUnclosedString($lastError)) {
return false;
}
// Control structures without body aren't syntax errors, they're incomplete.
if ($analysis->hasControlStructureWithoutBody()) {
return false;
}
return true;
}
public function hasBalancedBrackets(string $line): bool
{
return $this->bufferAnalyzer->analyze($line)->hasBalancedBrackets();
}
private function isUnterminatedComment(Error $error): bool
{
return $error->getRawMessage() === 'Unterminated comment';
}
private function isUnclosedString(Error $error): bool
{
$msg = $error->getRawMessage();
return $msg === 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE' ||
\strpos($msg, 'Unterminated string') !== false;
}
}

View File

@@ -0,0 +1,292 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
/**
* Read characters from stdin.
*/
class StdinReader
{
/** @var resource */
private $stream;
/**
* @param resource $stream
*/
public function __construct($stream = \STDIN)
{
$this->stream = $stream;
}
/**
* Check if there's pending input (useful for paste detection).
*/
public function hasPendingInput(): bool
{
// Memory streams don't support stream_select.
$meta = \stream_get_meta_data($this->stream);
if (isset($meta['stream_type']) && $meta['stream_type'] === 'MEMORY') {
return false;
}
\stream_set_blocking($this->stream, false);
$read = [$this->stream];
$write = null;
$except = null;
$ready = @\stream_select($read, $write, $except, 0, 0);
\stream_set_blocking($this->stream, true);
return $ready > 0;
}
/**
* Read all available input (used for handling pastes).
*
* @return string The pasted content
*/
public function readPastedContent(): string
{
// Memory streams don't support stream_select.
$meta = \stream_get_meta_data($this->stream);
if (isset($meta['stream_type']) && $meta['stream_type'] === 'MEMORY') {
$content = '';
while (($char = \fgetc($this->stream)) !== false) {
$content .= $char;
}
return $content;
}
\stream_set_blocking($this->stream, false);
$content = '';
$timeout = 50000;
$start = \microtime(true);
while ((\microtime(true) - $start) < ($timeout / 1000000)) {
$char = \fgetc($this->stream);
if ($char === false) {
$read = [$this->stream];
$write = null;
$except = null;
if (@\stream_select($read, $write, $except, 0, 1000) > 0) {
continue;
}
break;
}
$content .= $char;
}
\stream_set_blocking($this->stream, true);
return $content;
}
/**
* Read a single key press from the input stream.
*/
public function readKey(): Key
{
$char = \fgetc($this->stream);
if ($char === false) {
return new Key('', Key::TYPE_EOF);
}
// Escape sequences must be handled before paste detection.
if ($char === "\033") {
$escapeKey = $this->readEscapeSequence($char);
if ($escapeKey->getValue() === "\033[200~") {
return $this->readBracketedPaste();
}
return $escapeKey;
}
if ($this->hasPendingInput()) {
$pastedContent = $char.$this->readPastedContent();
// No ungetc support in PHP, so return any buffered content as a paste.
if (\strlen($pastedContent) > 1) {
return new Key($pastedContent, Key::TYPE_PASTE);
}
}
// CR/LF are handled as regular chars, not control characters.
$ord = \ord($char);
if ($ord < 32 && $char !== "\n" && $char !== "\r") {
return new Key($char, Key::TYPE_CONTROL);
}
if ($ord === 127) {
return new Key($char, Key::TYPE_CONTROL);
}
return new Key($char, Key::TYPE_CHAR);
}
/**
* Read an escape sequence from the input stream.
*/
protected function readEscapeSequence(string $prefix): Key
{
\stream_set_blocking($this->stream, false);
$sequence = $prefix;
$timeout = 100000;
$start = \microtime(true);
while ((\microtime(true) - $start) < ($timeout / 1000000)) {
$char = \fgetc($this->stream);
if ($char === false) {
\usleep(1000); // 1ms
continue;
}
$sequence .= $char;
if ($this->isCompleteEscapeSequence($sequence)) {
break;
}
if (\strlen($sequence) > 32) {
break;
}
}
\stream_set_blocking($this->stream, true);
return new Key($sequence, Key::TYPE_ESCAPE);
}
/**
* Check if an escape sequence is complete.
*/
protected function isCompleteEscapeSequence(string $sequence): bool
{
// Esc+Enter remaps (common in terminal keybind customization).
if ($sequence === "\033\r" || $sequence === "\033\n") {
return true;
}
// Arrow keys: \033[A-D, Home/End: \033[H, \033[F
if (\preg_match('/^\033\[[A-DHF]$/', $sequence)) {
return true;
}
// Function keys, Delete, bracketed paste: \033[NNN~ or \033[27;2;13~
if (\preg_match('/^\033\[\d+(?:[;:]\d+)*~$/', $sequence)) {
return true;
}
// Modified keys: \033[1;3C (Alt+Right), \033[1;5D (Ctrl+Left), etc.
if (\preg_match('/^\033\[\d+(?:[;:]\d+)*[A-Za-z]$/', $sequence)) {
return true;
}
// CSI-u protocol: \033[13;2u (Shift+Enter), \033[97;5u (Ctrl+A), etc.
if (\preg_match('/^\033\[\d+(?:[;:]\d+)*u$/', $sequence)) {
return true;
}
// SS3 keys: \033OM (keypad Enter), \033OA, etc.
if (\preg_match('/^\033O[A-Z]$/', $sequence)) {
return true;
}
// Alt+char: \033b, \033f, etc.
if (\preg_match('/^\033[a-z]$/i', $sequence)) {
return true;
}
return false;
}
/**
* Read bracketed paste content.
*
* Called after detecting \033[200~ sequence.
* Reads until \033[201~ is encountered.
*/
protected function readBracketedPaste(): Key
{
$content = '';
$escapeBuffer = '';
while (true) {
$char = \fgetc($this->stream);
if ($char === false) {
break;
}
if ($char === "\033") {
$escapeBuffer = $char;
\stream_set_blocking($this->stream, false);
$timeout = 10000;
$start = \microtime(true);
while ((\microtime(true) - $start) < ($timeout / 1000000)) {
$nextChar = \fgetc($this->stream);
if ($nextChar === false) {
\usleep(500);
continue;
}
$escapeBuffer .= $nextChar;
if ($escapeBuffer === "\033[201~") {
\stream_set_blocking($this->stream, true);
return new Key($content, Key::TYPE_PASTE);
}
if ($this->isCompleteEscapeSequence($escapeBuffer)) {
break;
}
if (\strlen($escapeBuffer) > 32) {
break;
}
}
\stream_set_blocking($this->stream, true);
$content .= $escapeBuffer;
$escapeBuffer = '';
} else {
$content .= $char;
}
}
return new Key($content, Key::TYPE_PASTE);
}
/**
* Get the underlying stream resource.
*
* @return resource
*/
public function getStream()
{
return $this->stream;
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
/**
* Token-based cursor navigation policy.
*/
class TokenNavigationPolicy
{
private const PHP_PREFIX_LENGTH = 6;
/**
* @param array $tokens
* @param array $tokenPositions
*/
public function findPreviousToken(array $tokens, array $tokenPositions, int $cursor): int
{
if (empty($tokens) || empty($tokenPositions)) {
return 0;
}
$cursorPos = $cursor + self::PHP_PREFIX_LENGTH;
$lastNonWhitespaceStart = 0;
foreach ($tokens as $index => $token) {
$tokenStart = $tokenPositions[$index]['start'];
$tokenEnd = $tokenPositions[$index]['end'];
if ($tokenStart < self::PHP_PREFIX_LENGTH) {
continue;
}
if (\is_array($token) && $token[0] === \T_WHITESPACE) {
continue;
}
// Cursor is inside this token — jump to its start
if ($tokenStart < $cursorPos && $cursorPos <= $tokenEnd) {
return $tokenStart - self::PHP_PREFIX_LENGTH;
}
// Cursor is before this token — jump to previous non-whitespace token
if ($tokenStart >= $cursorPos) {
return \max(0, $lastNonWhitespaceStart - self::PHP_PREFIX_LENGTH);
}
$lastNonWhitespaceStart = $tokenStart;
}
return \max(0, $lastNonWhitespaceStart - self::PHP_PREFIX_LENGTH);
}
/**
* @param array $tokens
* @param array $tokenPositions
*/
public function findNextToken(array $tokens, array $tokenPositions, int $cursor, int $lineLength): int
{
if (empty($tokens) || empty($tokenPositions)) {
return $lineLength;
}
$cursorPos = $cursor + self::PHP_PREFIX_LENGTH;
$skipCurrentToken = false;
foreach ($tokens as $index => $token) {
$tokenStart = $tokenPositions[$index]['start'];
$tokenEnd = $tokenPositions[$index]['end'];
if ($tokenStart < self::PHP_PREFIX_LENGTH) {
continue;
}
if (\is_array($token) && $token[0] === \T_WHITESPACE) {
continue;
}
if ($tokenStart <= $cursorPos && $cursorPos < $tokenEnd) {
$skipCurrentToken = true;
continue;
}
if ($tokenStart > $cursorPos || ($skipCurrentToken && $tokenStart >= $cursorPos)) {
return $tokenStart - self::PHP_PREFIX_LENGTH;
}
}
return $lineLength;
}
}

View File

@@ -0,0 +1,78 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
use Psy\Readline\Interactive\Layout\DisplayString;
use Psy\Readline\Interactive\Layout\SoftWrapCalculator;
/**
* Soft-wrapped visual row cursor navigation.
*/
class VisualNavigationPolicy
{
/**
* Move cursor by soft-wrapped rows on the current logical line.
*
* @return int|null New cursor position, or null if movement is not possible
*/
public function moveByRows(string $text, int $cursor, int $deltaRows, int $terminalWidth, int $promptWidth): ?int
{
if ($deltaRows === 0) {
return null;
}
$calculator = new SoftWrapCalculator($terminalWidth);
$promptWidth = \max(0, $promptWidth);
[$lineStart, $lineEnd] = $this->getLineBounds($text, $cursor);
$lineText = \mb_substr($text, $lineStart, $lineEnd - $lineStart);
$cursorInLine = \max(0, \min(\mb_strlen($lineText), $cursor - $lineStart));
$textBeforeCursor = \mb_substr($lineText, 0, $cursorInLine);
$currentAbsoluteColumn = $promptWidth + DisplayString::width($textBeforeCursor) + 1;
$lineEndAbsoluteColumn = $promptWidth + DisplayString::width($lineText) + 1;
$currentRow = $calculator->rowOffsetForAbsoluteColumn($currentAbsoluteColumn);
$targetRow = $currentRow + $deltaRows;
$maxRow = $calculator->rowOffsetForAbsoluteColumn($lineEndAbsoluteColumn);
if ($targetRow < 0 || $targetRow > $maxRow) {
return null;
}
$targetAbsoluteColumn = $currentAbsoluteColumn + ($deltaRows * $calculator->getTerminalWidth());
$targetAbsoluteColumn = \max($promptWidth + 1, \min($lineEndAbsoluteColumn, $targetAbsoluteColumn));
$targetTextWidth = \max(0, $targetAbsoluteColumn - $promptWidth - 1);
$targetOffset = DisplayString::offsetForWidth($lineText, $targetTextWidth);
return $lineStart + $targetOffset;
}
/**
* Get current logical line bounds as [start, end) offsets.
*
* @return array{int, int}
*/
private function getLineBounds(string $text, int $cursor): array
{
$beforeCursor = \mb_substr($text, 0, $cursor);
$lineStartPos = \mb_strrpos($beforeCursor, "\n");
$lineStart = $lineStartPos === false ? 0 : $lineStartPos + 1;
$lineEndPos = \mb_strpos($text, "\n", $lineStart);
$lineEnd = $lineEndPos === false ? \mb_strlen($text) : $lineEndPos;
return [$lineStart, $lineEnd];
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Input;
/**
* Word-based cursor navigation policy.
*/
class WordNavigationPolicy
{
/**
* Find the position of the start of the previous word.
*/
public function findPreviousWord(string $text, int $cursor): int
{
$beforeCursor = \rtrim(\mb_substr($text, 0, $cursor));
if (\preg_match('/[^\w]*(\w+)[^\w]*$/', $beforeCursor, $matches, \PREG_OFFSET_CAPTURE)) {
return $matches[1][1];
}
return 0;
}
/**
* Find the position of the start of the next word.
*/
public function findNextWord(string $text, int $cursor): int
{
$afterCursor = \mb_substr($text, $cursor);
if (\preg_match('/^\W*\w+/', $afterCursor, $matches)) {
return $cursor + \mb_strlen($matches[0]);
}
return \mb_strlen($text);
}
}

View File

@@ -0,0 +1,115 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
/**
* Owns terminal lifecycle for interactive readline sessions.
*/
class InteractiveSession
{
private Terminal $terminal;
private bool $active = false;
private bool $shutdownRegistered = false;
private bool $bracketedPasteEnabled = false;
public function __construct(Terminal $terminal)
{
$this->terminal = $terminal;
}
/**
* Start interactive terminal lifecycle.
*
* @throws \RuntimeException if raw mode cannot be enabled
*/
public function start(): void
{
if ($this->active) {
return;
}
$this->registerShutdownHandler();
if (!$this->terminal->enableRawMode()) {
throw new \RuntimeException('Unable to enable raw mode for interactive readline.');
}
if ($this->bracketedPasteEnabled) {
$this->terminal->enableBracketedPaste();
}
$this->active = true;
}
/**
* Stop interactive terminal lifecycle.
*/
public function stop(): void
{
if (!$this->active) {
return;
}
if ($this->terminal->isBracketedPasteEnabled()) {
$this->terminal->disableBracketedPaste();
}
$this->terminal->disableRawMode();
$this->active = false;
}
/**
* Enable or disable bracketed paste mode for the active session.
*/
public function setUseBracketedPaste(bool $enabled): void
{
$this->bracketedPasteEnabled = $enabled;
if (!$this->active) {
return;
}
if ($enabled) {
$this->terminal->enableBracketedPaste();
} else {
$this->terminal->disableBracketedPaste();
}
}
/**
* Determine whether the session is active.
*/
public function isActive(): bool
{
return $this->active;
}
/**
* Register a shutdown function to ensure the terminal is restored on exit.
*/
private function registerShutdownHandler(): void
{
if ($this->shutdownRegistered) {
return;
}
\register_shutdown_function([$this, 'stop']);
$this->shutdownRegistered = true;
}
/**
* Stop the session on destruction.
*/
public function __destruct()
{
$this->stop();
}
}

View File

@@ -0,0 +1,149 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Layout;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* Display-width string helpers for terminal rendering and navigation.
*
* Width modes:
* - width: plain text width, no formatting/ANSI stripping.
* - widthWithoutFormatting: parse formatter tags, then measure visible width.
* - widthWithoutAnsi: strip ANSI/control sequences from rendered output.
*/
class DisplayString
{
/**
* ANSI/control-sequence regexes for rendered terminal output.
*/
private const ANSI_CSI_RX = '/\x1B\[[0-9;?]*[ -\/]*[@-~]/';
private const ANSI_OSC_RX = '/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\\\)/';
/**
* Measure plain text width with no stripping.
*/
/** @var bool|null */
private static $hasHelperWidth;
public static function width(string $text): int
{
// Helper::width() added in Symfony 5.3; fall back to strlen() for older versions.
if (self::$hasHelperWidth ?? (self::$hasHelperWidth = \method_exists(Helper::class, 'width'))) {
return Helper::width($text);
}
/* @phan-suppress-next-line PhanDeprecatedFunction BC fallback for Symfony < 5.3. */
return Helper::strlen($text);
}
/**
* Measure width after applying/removing formatter markup.
*
* Use this for strings that intentionally contain formatter tags like
* "<info>...</info>" and should be measured by their rendered width.
*/
public static function widthWithoutFormatting(string $text, OutputFormatterInterface $formatter): int
{
return self::width(Helper::removeDecoration($formatter, $text));
}
/**
* Measure width from already-rendered terminal output.
*
* This strips ANSI/control sequences only; it does not parse formatter
* markup like "<info>...</info>".
*/
public static function widthWithoutAnsi(string $text): int
{
return self::width(self::stripAnsi($text));
}
/**
* Resolve the code-point offset for a target display width.
*
* The returned offset is always on a grapheme boundary.
*/
public static function offsetForWidth(string $text, int $targetWidth): int
{
if ($targetWidth <= 0 || $text === '') {
return 0;
}
$offset = 0;
$width = 0;
foreach (self::graphemes($text) as $grapheme) {
$graphemeWidth = self::width($grapheme);
if ($width + $graphemeWidth > $targetWidth) {
break;
}
$width += $graphemeWidth;
$offset += \mb_strlen($grapheme);
}
return $offset;
}
/**
* Truncate text to a maximum display width with optional ellipsis.
*/
public static function truncate(string $text, int $maxWidth, bool $withEllipsis = false): string
{
if ($maxWidth <= 0 || $text === '') {
return '';
}
if (self::width($text) <= $maxWidth) {
return $text;
}
if (!$withEllipsis || $maxWidth <= 3) {
return Helper::substr($text, 0, $maxWidth);
}
$targetWidth = $maxWidth - 3;
$offset = self::offsetForWidth($text, $targetWidth);
return \mb_substr($text, 0, $offset).'...';
}
/**
* Iterate grapheme clusters in a string.
*
* @return string[]
*/
private static function graphemes(string $text): array
{
if (\preg_match_all('/\X/u', $text, $matches) > 0) {
return $matches[0];
}
// Fallback to individual code points when grapheme matching fails.
$length = \mb_strlen($text);
$chars = [];
for ($i = 0; $i < $length; $i++) {
$chars[] = \mb_substr($text, $i, 1);
}
return $chars;
}
/**
* Remove terminal ANSI/control sequences from rendered text.
*/
private static function stripAnsi(string $text): string
{
return \preg_replace([self::ANSI_CSI_RX, self::ANSI_OSC_RX], '', $text) ?? $text;
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Layout;
/**
* Terminal soft-wrap geometry calculations.
*/
class SoftWrapCalculator
{
private int $terminalWidth;
public function __construct(int $terminalWidth)
{
$this->terminalWidth = \max(1, $terminalWidth);
}
public function getTerminalWidth(): int
{
return $this->terminalWidth;
}
/**
* Normalize an absolute terminal column (1-indexed) into visible column.
*/
public function normalizeColumn(int $absoluteColumn): int
{
$offset = \max(0, $absoluteColumn - 1);
return ($offset % $this->terminalWidth) + 1;
}
/**
* Get wrapped row offset for an absolute terminal column (1-indexed).
*/
public function rowOffsetForAbsoluteColumn(int $absoluteColumn): int
{
return \intdiv(\max(0, $absoluteColumn - 1), $this->terminalWidth);
}
/**
* Count wrapped rows occupied by text with this display width.
*
* Zero-width text still occupies one row.
*/
public function rowCountForDisplayWidth(int $displayWidth): int
{
if ($displayWidth <= 0) {
return 1;
}
return \intdiv($displayWidth - 1, $this->terminalWidth) + 1;
}
}

View File

@@ -0,0 +1,601 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
use Psy\Completion\CompletionEngine;
use Psy\Exception\BreakException;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Actions\ExpandHistoryOnTabAction;
use Psy\Readline\Interactive\Actions\HistoryExpansionAction;
use Psy\Readline\Interactive\Actions\InsertIndentOnTabAction;
use Psy\Readline\Interactive\Actions\NextHistoryAction;
use Psy\Readline\Interactive\Actions\PreviousHistoryAction;
use Psy\Readline\Interactive\Actions\SelfInsertAction;
use Psy\Readline\Interactive\Actions\TabAction;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Input\InputQueue;
use Psy\Readline\Interactive\Input\Key;
use Psy\Readline\Interactive\Input\KeyBindings;
use Psy\Readline\Interactive\Renderer\FrameRenderer;
use Psy\Readline\Interactive\Renderer\OverlayViewport;
use Psy\Readline\Interactive\Suggestion\SuggestionEngine;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
use Psy\Shell;
/**
* Interactive readline implementation.
*
* A pure-PHP readline implementation with better control over cursor
* positioning, tab completion display, and terminal interaction.
*/
class Readline
{
private const MODE_NORMAL = 'normal';
private const MODE_MENU = 'menu';
private Terminal $terminal;
private InputQueue $inputQueue;
private KeyBindings $bindings;
private History $history;
private HistorySearch $search;
private bool $multilineMode = false;
private ?Shell $shell = null;
private bool $requireSemicolons = false;
private Theme $theme;
private string $mode = self::MODE_NORMAL;
private ?TabAction $tabAction = null;
private ?ExpandHistoryOnTabAction $expandHistoryAction = null;
private bool $smartBrackets = true;
private bool $continueFrame = false;
private int $lastSubmitEscapeRows = 0;
private ?string $lastSubmittedText = null;
private bool $useSuggestions = false;
private SuggestionEngine $suggestionEngine;
private OverlayViewport $overlayViewport;
private FrameRenderer $frameRenderer;
private ?SuggestionResult $currentSuggestion = null;
/**
* Create a new interactive Readline instance.
*/
public function __construct(Terminal $terminal, ?KeyBindings $bindings = null, ?History $history = null)
{
$this->terminal = $terminal;
$this->inputQueue = new InputQueue($this->terminal);
$this->history = $history ?? new History();
$this->theme = new Theme();
$this->suggestionEngine = new SuggestionEngine($this->history);
$this->overlayViewport = new OverlayViewport($this->terminal);
$this->frameRenderer = new FrameRenderer($this->terminal, $this->overlayViewport, $this->theme);
$this->search = new HistorySearch($this->terminal, $this->history, $this->frameRenderer, $this->overlayViewport, $this->theme);
$this->bindings = $bindings ?? KeyBindings::createDefault($this->history, $this->search, $this->smartBrackets);
}
/**
* Set the theme for prompt strings and compact mode.
*/
public function setTheme(Theme $theme): void
{
$this->theme = $theme;
$this->frameRenderer->setTheme($theme);
$this->search->setTheme($theme);
}
/**
* Check whether compact input frame rendering is enabled.
*/
public function isInputFrameCompact(): bool
{
return $this->frameRenderer->isCompactInputFrame();
}
/**
* Get number of outer rows surrounding input content.
*/
public function getInputFrameOuterRowCount(): int
{
return $this->frameRenderer->getInputFrameOuterRowCount();
}
/**
* Get active prompt display width for the cursor's current line.
*/
public function getPromptWidthForCurrentLine(Buffer $buffer): int
{
return $this->frameRenderer->getPromptWidthForCurrentLine($buffer);
}
/**
* Set the Shell reference.
*/
public function setShell(Shell $shell): void
{
$this->shell = $shell;
if ($this->expandHistoryAction !== null) {
$this->expandHistoryAction->setHistoryExpansion(new HistoryExpansionAction($this->history, $this->shell));
}
}
/**
* Get the command highlighter for CommandAware registration.
*/
public function getCommandHighlighter(): Helper\CommandHighlighter
{
return $this->frameRenderer->getCommandHighlighter();
}
/**
* Set whether to require semicolons on all statements.
*
* By default, PsySH automatically inserts semicolons. When set to true,
* semicolons are strictly required.
*/
public function setRequireSemicolons(bool $require): void
{
$this->requireSemicolons = $require;
}
/**
* Set the CompletionEngine for context-aware tab completion.
*/
public function setCompletionEngine(CompletionEngine $completionEngine): void
{
if ($this->tabAction === null) {
$this->tabAction = new TabAction($completionEngine, $this->smartBrackets);
$this->expandHistoryAction = new ExpandHistoryOnTabAction(new HistoryExpansionAction($this->history, $this->shell));
$this->bindings->bind(
'control:i',
new InsertIndentOnTabAction(),
$this->expandHistoryAction,
$this->tabAction
);
} else {
$this->tabAction->setCompleter($completionEngine);
}
}
/**
* Check if the current input is a PsySH command.
*/
public function isCommand(string $input): bool
{
if ($this->shell === null) {
return false;
}
if (\preg_match('/([^\s]+?)(?:\s|$)/A', \ltrim($input), $match)) {
return $this->shell->has($match[1]);
}
return false;
}
/**
* Check if the input should be highlighted as a command (not in an open string or comment).
*/
private function isCommandInput(string $text): bool
{
return $this->isCommand($text) && !$this->isInOpenStringOrComment($text);
}
/**
* Check if currently in multi-line mode.
*/
public function isMultilineMode(): bool
{
return $this->multilineMode;
}
/**
* Check if input is in an open string or comment.
*/
public function isInOpenStringOrComment(string $input): bool
{
$tokens = @\token_get_all('<?php '.$input);
$last = \array_pop($tokens);
return $last === '"' || $last === '`' ||
(\is_array($last) && \in_array($last[0], [\T_ENCAPSED_AND_WHITESPACE, \T_START_HEREDOC, \T_COMMENT]));
}
/**
* Enter multi-line mode.
*/
public function enterMultilineMode(): void
{
$this->multilineMode = true;
}
/**
* Exit multi-line mode.
*/
public function exitMultilineMode(): void
{
$this->multilineMode = false;
}
/**
* Cancel multi-line mode without executing.
*/
public function cancelMultilineMode(): void
{
if (!$this->multilineMode) {
return;
}
$this->multilineMode = false;
}
/**
* Sync multiline mode with buffer content and invalidate the frame on transition.
*/
private function syncMultilineMode(string $text): void
{
$isMultiline = \strpos($text, "\n") !== false;
if ($this->multilineMode === $isMultiline) {
return;
}
if ($isMultiline) {
$this->enterMultilineMode();
} else {
$this->exitMultilineMode();
}
$this->terminal->invalidateFrame();
}
/**
* Read a line of input.
*
* @return string|false The input line, or false on EOF
*/
public function readline()
{
$this->mode = self::MODE_NORMAL;
$this->search->exit();
$this->clearSuggestion();
if ($this->continueFrame && $this->lastSubmittedText !== null) {
// Rewind the cursor past the escape newlines written by SubmitLineAction.
// Wrap in a frame render so the movement doesn't mark the terminal dirty
// (we're restoring the cursor to its known position, not making out-of-band changes).
$this->terminal->beginFrameRender();
if ($this->lastSubmitEscapeRows > 0) {
$this->terminal->moveCursorUp($this->lastSubmitEscapeRows);
}
$this->terminal->endFrameRender();
$this->frameRenderer->addHistoryLines($this->lastSubmittedText, $this->isCommandInput($this->lastSubmittedText));
$this->continueFrame = false;
} else {
$this->frameRenderer->reset();
}
$this->multilineMode = false;
$buffer = new Buffer($this->requireSemicolons);
$this->display($buffer);
try {
while (true) {
$key = $this->inputQueue->read();
if ($key->isEof()) {
$this->escapeCurrentFrameForAbort($buffer);
return false;
}
// Any keystroke clears error mode
$this->setInputFrameError(false);
if ($key->isPaste()) {
$this->handlePastedContent($key->getValue(), $buffer);
$this->syncMultilineMode($buffer->getText());
$this->display($buffer);
continue;
}
if ($this->search->isActive()) {
$result = $this->search->handleInput($key, $buffer);
if ($result === true) {
$this->search->display();
} else {
if ($result === null) {
$this->replayKey($key);
}
$this->syncMultilineMode($buffer->getText());
$this->display($buffer);
}
continue;
}
$action = $this->bindings->get($key);
if ($action === null && $key->isChar()) {
$action = new SelfInsertAction($key->getValue());
}
if ($action !== null) {
$textBefore = $buffer->getText();
$continue = $action->execute($buffer, $this->terminal, $this);
if ($continue) {
// Any buffer mutation outside of history navigation exits history mode.
if ($this->history->isInHistory()
&& $buffer->getText() !== $textBefore
&& !$action instanceof PreviousHistoryAction
&& !$action instanceof NextHistoryAction
) {
$this->history->reset();
}
if ($this->search->isActive()) {
$this->search->display();
} else {
$this->syncMultilineMode($buffer->getText());
$this->updateSuggestion($buffer);
$this->display($buffer);
}
} else {
$line = $buffer->getText();
$this->clearSuggestion();
if (!$this->isCommand($line) || $this->isInOpenStringOrComment($line)) {
$this->exitMultilineMode();
}
$this->history->reset();
$this->lastSubmittedText = $line;
return $line;
}
} else {
$this->terminal->bell();
}
}
} catch (BreakException $e) {
// Shell.php will write the newline
return false;
}
}
/**
* Handle pasted content (potentially multi-line).
*/
private function handlePastedContent(string $content, Buffer $buffer): void
{
$content = \strtr(\str_replace("\r\n", "\n", $content), "\r", "\n");
$buffer->insert($content);
}
/**
* Display the prompt and buffer.
*/
private function display(Buffer $buffer): void
{
$searchTerm = $this->history->isInHistory() ? $this->history->getSearchTerm() : null;
$text = $buffer->getText();
$this->frameRenderer->render($buffer, $this->currentSuggestion, $searchTerm, $this->isCommandInput($text));
}
/**
* Clear the overlay and re-render.
*/
public function clearOverlay(Buffer $buffer): void
{
$this->frameRenderer->clearOverlay($buffer);
}
/**
* Render overlay lines and redraw the frame.
*
* @param string[] $lines
*/
public function renderOverlay(Buffer $buffer, array $lines): void
{
$this->frameRenderer->setOverlayLines($lines);
$this->display($buffer);
}
/**
* Read the next key event from the queue.
*/
public function readNextKey(): Key
{
return $this->inputQueue->read();
}
/**
* Replay a key event so the main loop can consume it.
*/
public function replayKey(Key $key): void
{
$this->inputQueue->replay($key);
}
/**
* Internal accessor for interactive readline internals and tests.
*
* @internal
*/
public function getTabAction(): ?TabAction
{
return $this->tabAction;
}
/**
* Get available overlay rows for completion/search UI.
*/
public function getOverlayAvailableRows(bool $collapsed): int
{
return $this->overlayViewport->getAvailableRows($collapsed);
}
/**
* Set the input frame error state (red-tinted background for syntax errors).
*/
public function setInputFrameError(bool $error): void
{
$this->frameRenderer->setErrorMode($error);
}
/**
* Clear previously submitted lines from the current input frame.
*/
public function clearPreviousLines(): void
{
$this->frameRenderer->clearHistoryLines();
}
/**
* Set whether the next readline() call should continue the current frame.
*/
public function setContinueFrame(bool $continue): void
{
$this->continueFrame = $continue;
}
/**
* Store the number of newlines written by SubmitLineAction to escape the frame.
*/
public function setLastSubmitEscapeRows(int $rows): void
{
$this->lastSubmitEscapeRows = $rows;
}
/**
* Escape below the current frame before aborting back to Shell.php.
*
* Shell.php writes the final newline after readline returns false or a
* BreakException bubbles up, so only escape the remaining frame rows here.
*/
public function escapeCurrentFrameForAbort(Buffer $buffer): void
{
$lineCount = \substr_count($buffer->getText(), "\n") + 1;
$remainingInputLines = $lineCount - $buffer->getCurrentLineNumber();
$escapeRows = \max(0, $remainingInputLines + $this->getInputFrameOuterRowCount() - 1);
if ($escapeRows > 0) {
$this->terminal->write(\str_repeat("\n", $escapeRows));
}
}
/**
* Get the history.
*/
public function getHistory(): History
{
return $this->history;
}
/**
* Enable or disable inline suggestions.
*/
public function setUseSuggestions(bool $enabled): void
{
$this->useSuggestions = $enabled;
}
/**
* Enable or disable syntax highlighting.
*/
public function setUseSyntaxHighlighting(bool $enabled): void
{
$this->frameRenderer->setUseSyntaxHighlighting($enabled);
}
/**
* Get the suggestion engine.
*/
public function getSuggestionEngine(): SuggestionEngine
{
return $this->suggestionEngine;
}
/**
* Get the current suggestion.
*/
public function getCurrentSuggestion(): ?SuggestionResult
{
return $this->currentSuggestion;
}
/**
* Clear the current suggestion.
*/
public function clearSuggestion(): void
{
$this->currentSuggestion = null;
$this->suggestionEngine->clearCache();
}
/**
* Update suggestion based on current buffer state.
*/
private function updateSuggestion(Buffer $buffer): void
{
if (!$this->useSuggestions) {
return;
}
if ($this->mode !== self::MODE_NORMAL || $this->search->isActive() || $this->multilineMode) {
$this->clearSuggestion();
return;
}
$this->currentSuggestion = $this->suggestionEngine->getSuggestion(
$buffer->getText(),
$buffer->getCursor()
);
}
/**
* Get the history search state machine.
*/
public function getSearch(): HistorySearch
{
return $this->search;
}
/**
* Enter completion menu mode.
*/
public function enterMenuMode(): void
{
$this->mode = self::MODE_MENU;
}
/**
* Exit completion menu mode.
*/
public function exitMenuMode(): void
{
if ($this->mode === self::MODE_MENU) {
$this->mode = self::MODE_NORMAL;
}
}
}

View File

@@ -0,0 +1,646 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Renderer;
use Psy\Formatter\CodeFormatter;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Helper\CommandHighlighter;
use Psy\Readline\Interactive\Input\Buffer;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Layout\DisplayString;
use Psy\Readline\Interactive\Layout\SoftWrapCalculator;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
use Psy\Readline\Interactive\Terminal;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
/**
* Frame-buffered renderer for interactive readline.
*
* Builds the visible area (input + overlay) as logical lines and lets the
* terminal perform soft wrapping. The renderer tracks wrapped row occupancy
* itself so cursor positioning and overlay budgeting remain correct.
*/
class FrameRenderer
{
private const CLEAR_TO_END_OF_LINE = "\033[K";
private const INPUT_FRAME_PADDING_ROWS = 2;
private Terminal $terminal;
private OverlayViewport $viewport;
private Theme $theme;
private CommandHighlighter $commandHighlighter;
/** @var string[] Lines currently displayed on the terminal. */
private array $previousFrame = [];
/** Which wrapped frame row the terminal cursor is currently on (0-indexed). */
private int $cursorFrameRow = 0;
/** @var string[] Overlay lines to display below the input. */
private array $overlayLines = [];
/** @var string[] Previously submitted lines rendered above current input within the frame. */
private array $historyLines = [];
/** Cached wrapped row count for history lines, invalidated on change. */
private int $historyRowCount = 0;
private bool $errorMode = false;
private bool $useSyntaxHighlighting = true;
private ?int $lastTerminalWidth = null;
private ?int $lastTerminalHeight = null;
private ?SoftWrapCalculator $softWrapCalculator = null;
private ?int $softWrapCachedWidth = null;
/** @var array<string, int> Cached row counts keyed by line content. */
private array $lineRowCache = [];
/** @var array<string, int> Cached prompt widths keyed by "line:decorated". */
private array $promptWidthCache = [];
public function __construct(Terminal $terminal, OverlayViewport $viewport, ?Theme $theme = null)
{
$this->terminal = $terminal;
$this->viewport = $viewport;
$this->theme = $theme ?? new Theme();
$this->commandHighlighter = new CommandHighlighter();
}
/**
* Get the command highlighter for CommandAware registration.
*/
public function getCommandHighlighter(): CommandHighlighter
{
return $this->commandHighlighter;
}
/**
* Set the theme for prompt strings and compact mode.
*/
public function setTheme(Theme $theme): void
{
$this->theme = $theme;
$this->promptWidthCache = [];
}
/**
* Enable or disable syntax highlighting for input rendering.
*/
public function setUseSyntaxHighlighting(bool $enabled): void
{
$this->useSyntaxHighlighting = $enabled;
}
/**
* Check whether compact input frame rendering is enabled.
*/
public function isCompactInputFrame(): bool
{
return $this->theme->compact();
}
/**
* Set the error mode for the input frame.
*/
public function setErrorMode(bool $error): void
{
$this->errorMode = $error;
}
/**
* Get number of outer rows surrounding input content.
*/
public function getInputFrameOuterRowCount(): int
{
return $this->theme->compact() ? 0 : self::INPUT_FRAME_PADDING_ROWS;
}
/**
* Get active prompt display width for the cursor's current line.
*/
public function getPromptWidthForCurrentLine(Buffer $buffer): int
{
$lineNumber = 0;
if (\strpos($buffer->getText(), "\n") !== false) {
$lineNumber = $buffer->getCurrentLineNumber();
}
return $this->getPromptWidthForLine($lineNumber, $this->terminal->getFormatter());
}
/**
* Get the prompt string for a given line number.
*/
private function getPromptForLine(int $lineNumber): string
{
return $lineNumber === 0 ? $this->theme->prompt() : $this->theme->bufferPrompt();
}
/**
* Get the display width of the prompt for a given line number.
*/
private function getPromptWidthForLine(int $lineNumber, ?OutputFormatterInterface $formatter = null): int
{
$key = ($lineNumber === 0 ? '0' : '1').($formatter !== null ? ':f' : ':p');
if (isset($this->promptWidthCache[$key])) {
return $this->promptWidthCache[$key];
}
$prompt = $this->getPromptForLine($lineNumber);
$width = ($formatter === null)
? DisplayString::width($prompt)
: DisplayString::widthWithoutFormatting($prompt, $formatter);
$this->promptWidthCache[$key] = $width;
return $width;
}
/**
* Add a previously submitted input to the in-frame history.
*/
public function addHistoryLines(string $text, bool $isCommand = false): void
{
$newLines = $this->formatHighlightedLinesWithPrompts($this->formatInputLines($text, null, $isCommand));
\array_push($this->historyLines, ...$newLines);
foreach ($newLines as $line) {
$this->historyRowCount += $this->lineRowCount($line);
}
}
/**
* Clear previously submitted lines rendered above the current input.
*/
public function clearHistoryLines(): void
{
$this->historyLines = [];
$this->historyRowCount = 0;
}
/**
* Set overlay lines to display below the input.
*
* @param string[] $lines
*/
public function setOverlayLines(array $lines): void
{
$this->overlayLines = $lines;
}
/**
* Clear the overlay and re-render.
*/
public function clearOverlay(Buffer $buffer): void
{
$this->overlayLines = [];
$this->render($buffer, null);
}
/**
* Render the full frame (input + overlay) to the terminal.
*/
public function render(Buffer $buffer, ?SuggestionResult $suggestion, ?string $historySearchTerm = null, bool $isCommand = false): void
{
$isMultiline = \strpos($buffer->getText(), "\n") !== false;
$inputLines = $this->buildInputLines($buffer, $isMultiline, $suggestion, $historySearchTerm, $isCommand);
$this->viewport->setInputRowCount($this->getFrameRowCount($inputLines));
$frame = \array_merge($inputLines, $this->overlayLines);
[$cursorRow, $cursorColumn] = $this->getCursorPosition($buffer, $isMultiline);
$this->syncFrame($frame, $cursorRow, $cursorColumn);
$this->terminal->flush();
}
/**
* Render the search UI: preview in the input frame, search field + results below.
*
* @param string $previewText Text to show in the input frame as a preview
* @param string $searchPrompt The search field line (cursor goes here)
*/
public function renderSearchFrame(string $previewText, string $searchPrompt): void
{
// Show a concise, truncated preview on the prompt line.
$promptLine = $this->getPromptForLine(0);
if ($previewText !== '') {
$collapsed = History::collapseToSingleLine($previewText);
$promptWidth = $this->getPromptWidthForLine(0, $this->terminal->getFormatter());
$maxWidth = $this->getTerminalWidth() - $promptWidth;
$truncated = DisplayString::truncate($collapsed, $maxWidth, true);
$promptLine .= OutputFormatter::escape($truncated);
}
$inputLines = $this->wrapInInputFrame([$promptLine]);
$inputRowCount = $this->getFrameRowCount($inputLines);
$searchPromptRows = $this->lineRowCount($searchPrompt);
$this->viewport->setInputRowCount($inputRowCount + $searchPromptRows);
// Frame = input frame + search field + overlay results
$frame = \array_merge($inputLines, [$searchPrompt], $this->overlayLines);
// Position cursor at end of search prompt, below the input frame
$calculator = $this->getSoftWrapCalculator();
$absoluteColumn = DisplayString::widthWithoutAnsi($searchPrompt) + 1;
$searchLineRow = $this->getRowOffsetBeforeLine($frame, \count($inputLines));
$cursorRow = $searchLineRow + $calculator->rowOffsetForAbsoluteColumn($absoluteColumn);
$cursorColumn = $calculator->normalizeColumn($absoluteColumn);
$this->syncFrame($frame, $cursorRow, $cursorColumn);
$this->terminal->flush();
}
/**
* Reset renderer state (call when starting a new readline session).
*/
public function reset(): void
{
$this->previousFrame = [];
$this->cursorFrameRow = 0;
$this->overlayLines = [];
$this->historyLines = [];
$this->historyRowCount = 0;
$this->errorMode = false;
$this->lastTerminalWidth = null;
$this->lastTerminalHeight = null;
$this->lineRowCache = [];
}
/**
* Build the input section of the frame.
*
* @return string[]
*/
private function buildInputLines(Buffer $buffer, bool $isMultiline, ?SuggestionResult $suggestion, ?string $historySearchTerm = null, bool $isCommand = false): array
{
$text = $buffer->getText();
$contentLines = [];
if ($isMultiline) {
$contentLines = $this->formatHighlightedLinesWithPrompts($this->formatInputLines($text, $historySearchTerm, $isCommand));
} else {
$line = $this->getPromptForLine(0).\implode("\n", $this->formatInputLines($text, $historySearchTerm, $isCommand));
if ($suggestion !== null) {
$line = $this->appendSuggestionGhostText($line, $buffer, $text, $suggestion);
}
$contentLines[] = $line;
}
return $this->wrapInInputFrame($contentLines);
}
/**
* Wrap content lines in the input frame (background, padding).
*
* @param string[] $contentLines
*
* @return string[]
*/
private function wrapInInputFrame(array $contentLines): array
{
if ($this->theme->compact()) {
return \array_merge($this->historyLines, $contentLines);
}
$styleName = $this->errorMode ? 'input_frame_error' : 'input_frame';
$formatter = $this->terminal->getFormatter();
$inputFrameStyle = ($formatter->isDecorated() && $formatter->hasStyle($styleName))
? $formatter->getStyle($styleName)
: null;
$framedLines = [''];
foreach (['', ...$this->historyLines, ...$contentLines, ''] as $line) {
$lineWithClear = $line.self::CLEAR_TO_END_OF_LINE;
$framedLines[] = $inputFrameStyle ? $inputFrameStyle->apply($lineWithClear) : $lineWithClear;
}
$framedLines[] = '';
return $framedLines;
}
/**
* Format raw input text into prompt-prefixed lines.
*
* @return string[]
*/
private function formatHighlightedLinesWithPrompts(array $lines): array
{
$result = [];
foreach ($lines as $i => $line) {
$result[] = $this->getPromptForLine($i).$line;
}
return $result;
}
/**
* Highlight all occurrences of a search term in text.
*
* Uses smart case (case-insensitive unless the term contains uppercase).
*/
private function highlightSearchTerm(string $text, string $term): string
{
$formatter = $this->terminal->getFormatter();
if (!$formatter->isDecorated() || !$formatter->hasStyle('input_highlight')) {
return $text;
}
$style = $formatter->getStyle('input_highlight');
$pattern = '/'.\preg_quote($term, '/').'/u'.(History::isSearchCaseSensitive($term) ? '' : 'i');
$highlighted = \preg_replace_callback($pattern, fn (array $match) => $style->apply($match[0]), $text);
return $highlighted ?? $text;
}
/**
* Append single-line suggestion ghost text when there is room.
*/
private function appendSuggestionGhostText(string $line, Buffer $buffer, string $text, SuggestionResult $suggestion): string
{
// Completion overlays own the viewport; don't mix ghost text with menus.
if (!empty($this->overlayLines)) {
return $line;
}
$absoluteCursorColumn = $this->getPromptWidthForLine(0, $this->terminal->getFormatter())
+ DisplayString::width(\mb_substr($text, 0, $buffer->getCursor())) + 1;
$cursorColumn = $this->getSoftWrapCalculator()->normalizeColumn($absoluteCursorColumn);
$maxWidth = $this->getTerminalWidth() - $cursorColumn + 1;
if ($maxWidth <= 0) {
return $line;
}
$suggestionText = DisplayString::truncate($suggestion->getDisplayText(), $maxWidth, true);
if ($suggestionText === '') {
return $line;
}
return $line.$this->terminal->format('<whisper>'.OutputFormatter::escape($suggestionText).'</whisper>');
}
/**
* Get the wrapped frame row and terminal column where the cursor should be.
*
* @return array{int, int}
*/
private function getCursorPosition(Buffer $buffer, bool $isMultiline): array
{
$text = $buffer->getText();
$historyRowOffset = $this->historyRowCount;
if ($isMultiline) {
$lines = \explode("\n", $text);
$lineNum = $buffer->getCurrentLineNumber();
$promptWidth = $this->getPromptWidthForLine($lineNum, $this->terminal->getFormatter());
$charsBeforeLine = 0;
$rowsBeforeLine = 0;
for ($i = 0; $i < $lineNum; $i++) {
$charsBeforeLine += \mb_strlen($lines[$i]) + 1;
$rowsBeforeLine += $this->lineRowCount($this->getPromptForLine($i).($lines[$i] ?? ''));
}
$lineText = $lines[$lineNum] ?? '';
$cursorInLine = \max(0, \min(\mb_strlen($lineText), $buffer->getCursor() - $charsBeforeLine));
$textBeforeCursor = \mb_substr($lineText, 0, $cursorInLine);
$absoluteColumn = $promptWidth + DisplayString::width($textBeforeCursor) + 1;
$calculator = $this->getSoftWrapCalculator();
return [
$this->getInputFrameOuterRowCount() + $historyRowOffset + $rowsBeforeLine + $calculator->rowOffsetForAbsoluteColumn($absoluteColumn),
$calculator->normalizeColumn($absoluteColumn),
];
}
$textBeforeCursor = \mb_substr($text, 0, $buffer->getCursor());
$absoluteColumn = $this->getPromptWidthForLine(0, $this->terminal->getFormatter())
+ DisplayString::width($textBeforeCursor) + 1;
$calculator = $this->getSoftWrapCalculator();
return [
$this->getInputFrameOuterRowCount() + $historyRowOffset + $calculator->rowOffsetForAbsoluteColumn($absoluteColumn),
$calculator->normalizeColumn($absoluteColumn),
];
}
/**
* Sync a new frame to the terminal.
*
* @param string[] $newFrame
*/
private function syncFrame(array $newFrame, int $cursorRow, int $cursorColumn): void
{
$terminalWidth = $this->getTerminalWidth();
$terminalHeight = \max(1, $this->terminal->getHeight());
$sizeChanged = $this->lastTerminalWidth !== null && (
$terminalWidth !== $this->lastTerminalWidth || $terminalHeight !== $this->lastTerminalHeight
);
$dirty = $this->terminal->isDirty();
$oldFrame = ($sizeChanged || $dirty) ? [] : $this->previousFrame;
$firstChangedLine = $this->findFirstChangedLine($oldFrame, $newFrame);
// Wrapped row tracking is invalid after row-affecting out-of-band writes/resizes,
// or if we can no longer trust the previous frame contents.
if ($sizeChanged || $this->terminal->isCursorRowUnknown()) {
$this->cursorFrameRow = 0;
}
$this->terminal->beginFrameRender();
try {
$currentRow = $this->cursorFrameRow;
if ($firstChangedLine !== null) {
$oldStartRow = $this->getRowOffsetBeforeLine($oldFrame, $firstChangedLine);
$newStartRow = $this->getRowOffsetBeforeLine($newFrame, $firstChangedLine);
$currentRow = $this->moveCursorToRow($currentRow, $oldStartRow);
$this->terminal->write("\r");
$this->terminal->clearToEndOfScreen();
$suffix = \array_slice($newFrame, $firstChangedLine);
foreach ($suffix as $i => $line) {
if ($i > 0) {
$this->terminal->write("\n");
}
$this->terminal->write($line);
}
$currentRow = empty($suffix)
? $newStartRow
: $newStartRow + $this->getFrameRowCount($suffix) - 1;
}
$currentRow = $this->moveCursorToRow($currentRow, $cursorRow);
$this->terminal->moveCursorToColumn($cursorColumn);
$this->previousFrame = $newFrame;
$this->cursorFrameRow = $cursorRow;
$this->lastTerminalWidth = $terminalWidth;
$this->lastTerminalHeight = $terminalHeight;
} finally {
$this->terminal->endFrameRender();
}
}
/**
* Count how many terminal rows a frame occupies after soft wrapping.
*
* @param string[] $frame
*/
private function getFrameRowCount(array $frame): int
{
$rows = 0;
foreach ($frame as $line) {
$rows += $this->lineRowCount($line);
}
return \max(1, $rows);
}
/**
* Count wrapped terminal rows for a single logical line.
*/
private function lineRowCount(string $line): int
{
if (isset($this->lineRowCache[$line])) {
return $this->lineRowCache[$line];
}
$width = DisplayString::widthWithoutAnsi($line);
$rows = $this->getSoftWrapCalculator()->rowCountForDisplayWidth($width);
$this->lineRowCache[$line] = $rows;
return $rows;
}
/**
* Format input into ANSI-safe lines for prompt rendering.
*
* @return string[]
*/
private function formatInputLines(string $text, ?string $historySearchTerm = null, bool $isCommand = false): array
{
if ($text === '') {
return [''];
}
if ($historySearchTerm !== null) {
return \explode("\n", $this->highlightSearchTerm($text, $historySearchTerm));
}
if (!$this->useSyntaxHighlighting) {
return \explode("\n", $text);
}
if ($isCommand) {
return $this->commandHighlighter->highlightLines($text, $this->terminal->getFormatter());
}
return CodeFormatter::formatInputLines($text, $this->terminal->getFormatter());
}
private function getTerminalWidth(): int
{
return \max(1, $this->terminal->getWidth());
}
/**
* Recalculate the cached history row count after line row cache invalidation.
*/
private function recalculateHistoryRowCount(): void
{
$this->historyRowCount = 0;
foreach ($this->historyLines as $line) {
$this->historyRowCount += $this->lineRowCount($line);
}
}
private function getSoftWrapCalculator(): SoftWrapCalculator
{
$width = $this->getTerminalWidth();
if ($this->softWrapCalculator === null || $this->softWrapCachedWidth !== $width) {
$this->softWrapCalculator = new SoftWrapCalculator($width);
$this->softWrapCachedWidth = $width;
$this->lineRowCache = [];
$this->recalculateHistoryRowCount();
}
return $this->softWrapCalculator;
}
/**
* Find the first logical line that differs between two frames.
*
* @param string[] $oldFrame
* @param string[] $newFrame
*/
private function findFirstChangedLine(array $oldFrame, array $newFrame): ?int
{
$oldCount = \count($oldFrame);
$newCount = \count($newFrame);
$sharedCount = \min($oldCount, $newCount);
for ($i = 0; $i < $sharedCount; $i++) {
if ($oldFrame[$i] !== $newFrame[$i]) {
return $i;
}
}
if ($oldCount !== $newCount) {
return $sharedCount;
}
return null;
}
/**
* Get total wrapped rows occupied by lines before the given logical line index.
*
* @param string[] $frame
*/
private function getRowOffsetBeforeLine(array $frame, int $lineIndex): int
{
$rows = 0;
$lineIndex = \max(0, \min($lineIndex, \count($frame)));
for ($i = 0; $i < $lineIndex; $i++) {
$rows += $this->lineRowCount($frame[$i]);
}
return $rows;
}
/**
* Move cursor vertically between wrapped frame rows.
*/
private function moveCursorToRow(int $currentRow, int $targetRow): int
{
if ($targetRow < $currentRow) {
$this->terminal->moveCursorUp($currentRow - $targetRow);
} elseif ($targetRow > $currentRow) {
$this->terminal->moveCursorDown($targetRow - $currentRow);
}
return $targetRow;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Renderer;
use Psy\Readline\Interactive\Terminal;
/**
* Calculates available terminal space for overlays rendered below the input.
*
* Tracks how many terminal rows the input currently occupies and provides
* the remaining rows available for overlay content (tab completion menus, etc.).
*/
class OverlayViewport
{
private Terminal $terminal;
/** How many terminal rows the current input (prompt + buffer) occupies. */
private int $inputRowCount = 1;
public function __construct(Terminal $terminal)
{
$this->terminal = $terminal;
}
/**
* Update how many terminal rows the input currently occupies.
*
* Called by FrameRenderer after each render.
*/
public function setInputRowCount(int $rows): void
{
$this->inputRowCount = \max(1, $rows);
}
/**
* Get the maximum number of rows available for overlay content.
*
* Subtracts the input height from the terminal height, reserving
* one row for breathing room. In compact mode, also caps at half
* the terminal height.
*
* @param bool $compact If true, use at most half the terminal height
*/
public function getAvailableRows(bool $compact = false): int
{
$terminalHeight = $this->terminal->getHeight();
// Reserve 1 row for status line / breathing room
$available = $terminalHeight - $this->inputRowCount - 1;
if ($compact) {
$halfTerminal = (int) \floor($terminalHeight / 2);
$available = \min($available, $halfTerminal);
}
return \max(1, $available);
}
}

View File

@@ -0,0 +1,179 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion;
use Psy\Readline\Interactive\Helper\DebugLog;
use Psy\Readline\Interactive\Input\History;
/**
* Frecency (frequency + recency) index for history-based ranking.
*
* Analyzes command history to build a weighted map of important words.
* Uses both frequency (how often) and recency (how recent) to rank words.
*/
class FrecencyIndex
{
/** Word to frecency score map, keyed by word. */
private array $scores = [];
/** Common PHP keywords to exclude. */
private const STOPWORDS = [
// PHP keywords
'if', 'else', 'elseif', 'endif', 'for', 'foreach', 'endforeach',
'while', 'endwhile', 'do', 'switch', 'case', 'default', 'break',
'continue', 'return', 'function', 'class', 'interface', 'trait',
'extends', 'implements', 'namespace', 'use', 'as', 'new', 'clone',
'public', 'protected', 'private', 'static', 'final', 'abstract',
'const', 'var', 'global', 'echo', 'print', 'isset', 'empty', 'unset',
'exit', 'die', 'eval', 'include', 'require', 'include_once', 'require_once',
'and', 'or', 'xor', 'not', 'true', 'false', 'null', 'try', 'catch',
'finally', 'throw', 'instanceof', 'goto', 'yield', 'from', 'declare',
// Common short words (but NOT 'array' - it's useful!)
'this', 'self', 'parent', 'list', 'fn',
];
/** Time decay factor (higher = more weight on recent). */
private const RECENCY_DECAY = 0.95;
/** Number of days to consider for recency. */
private const RECENCY_WINDOW = 30;
/**
* Build frecency index from history.
*/
public function __construct(History $history)
{
$this->buildIndex($history);
}
/**
* Get frecency score for a word.
*
* Returns 0.0 for unknown words.
*/
public function getScore(string $word): float
{
return $this->scores[\strtolower($word)] ?? 0.0;
}
/**
* Get all scored words.
*
* @return float[] Keyed by word
*/
public function getAllScores(): array
{
return $this->scores;
}
/**
* Check if a word exists in the index.
*/
public function hasWord(string $word): bool
{
return isset($this->scores[\strtolower($word)]);
}
/**
* Build the frecency index from history entries.
*/
private function buildIndex(History $history): void
{
$now = \time();
$windowStart = $now - (self::RECENCY_WINDOW * 86400); // Days to seconds
$historyCount = $history->getCount();
DebugLog::log('FrecencyIndex', 'BUILD_START', [
'history_count' => $historyCount,
'window_days' => self::RECENCY_WINDOW,
]);
$wordData = [];
$processedEntries = 0;
foreach ($history->getAll() as $entry) {
$timestamp = $entry['timestamp'] ?? $now;
if ($timestamp < $windowStart) {
continue;
}
$processedEntries++;
$words = $this->extractWords($entry['command']);
$age = ($now - $timestamp) / 86400;
$recencyWeight = \pow(self::RECENCY_DECAY, $age);
if ($processedEntries <= 3) {
DebugLog::log('FrecencyIndex', 'ENTRY', [
'num' => $processedEntries,
'age' => \sprintf('%.1f', $age).'d',
'weight' => \sprintf('%.3f', $recencyWeight),
'words' => \implode(', ', $words),
]);
}
foreach ($words as $word) {
if (!isset($wordData[$word])) {
$wordData[$word] = [
'count' => 0,
'totalWeight' => 0.0,
];
}
$wordData[$word]['count']++;
$wordData[$word]['totalWeight'] += $recencyWeight;
}
}
DebugLog::log('FrecencyIndex', 'PROCESSED', [
'entries' => $processedEntries,
'words' => \count($wordData),
]);
foreach ($wordData as $word => $data) {
$avgRecency = $data['totalWeight'] / $data['count'];
$frequency = $data['count'];
$score = $frequency * $avgRecency;
$this->scores[$word] = \min(100.0, $score * 10);
}
\arsort($this->scores);
$top = \array_slice($this->scores, 0, 10, true);
foreach ($top as $word => $score) {
$count = $wordData[$word]['count'];
$avgRec = $wordData[$word]['totalWeight'] / $count;
DebugLog::log('FrecencyIndex', 'TOP_WORD', [
'word' => $word,
'score' => \sprintf('%.1f', $score),
'freq' => $count,
'avg_recency' => \sprintf('%.3f', $avgRec),
]);
}
}
/**
* Extract meaningful words from a command.
*
* @return string[] Normalized word list
*/
private function extractWords(string $command): array
{
return \array_values(\array_filter(
WordExtractor::extractNormalizedIdentifiers($command),
fn (string $word): bool => !\ctype_digit($word) && !\in_array($word, self::STOPWORDS, true)
));
}
}

View File

@@ -0,0 +1,155 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion\Source;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
/**
* Provides function/method parameter signature suggestions.
*
* When cursor is inside empty parentheses after a function name,
* suggests the parameter signature.
*/
class CallSignatureSource implements SourceInterface
{
/** @var array<string, ?string> Cached formatted signatures keyed by function name. */
private array $signatureCache = [];
/**
* {@inheritdoc}
*/
public function getSuggestion(string $buffer, int $cursorPosition): ?SuggestionResult
{
if (\preg_match('/(?:->|::)\s*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\(\s*$/u', $buffer)) {
return null;
}
if (!\preg_match('/([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\(\s*$/u', $buffer, $matches)) {
return null;
}
$functionName = $matches[1];
$signature = $this->getFunctionSignature($functionName);
if ($signature === null) {
return null;
}
return SuggestionResult::forAppend(
$signature,
SuggestionResult::SOURCE_CALL_SIGNATURE,
$cursorPosition,
$signature.')'
);
}
/**
* {@inheritdoc}
*/
public function getPriority(): int
{
return 150; // Highest priority - very specific context
}
/**
* Get function signature for display.
*
* Returns parameter list like: $array1, $array2, ...$arrays
*/
private function getFunctionSignature(string $functionName): ?string
{
if (\array_key_exists($functionName, $this->signatureCache)) {
return $this->signatureCache[$functionName];
}
$signature = null;
try {
if (\function_exists($functionName)) {
$reflection = new \ReflectionFunction($functionName);
$signature = $this->formatParameters($reflection->getParameters());
}
} catch (\ReflectionException $e) {
// Leave as null.
}
$this->signatureCache[$functionName] = $signature;
return $signature;
}
/**
* Format reflection parameters into readable signature.
*
* @param \ReflectionParameter[] $parameters
*/
private function formatParameters(array $parameters): string
{
if (empty($parameters)) {
return '';
}
$parts = [];
foreach ($parameters as $param) {
$paramStr = '';
if ($param->isVariadic()) {
$paramStr .= '...';
}
$paramStr .= '$'.$param->getName();
if ($param->isOptional() && !$param->isVariadic()) {
$paramStr .= ' = '.$this->formatDefaultValue($param);
}
$parts[] = $paramStr;
}
return \implode(', ', $parts);
}
/**
* Format a parameter's default value for display in the signature.
*/
private function formatDefaultValue(\ReflectionParameter $parameter): string
{
try {
$default = $parameter->getDefaultValue();
} catch (\ReflectionException $e) {
return '...';
}
if ($default === null) {
return 'null';
}
if (\is_bool($default)) {
return $default ? 'true' : 'false';
}
if (\is_string($default)) {
if (\strlen($default) > 20) {
return '"'.\substr($default, 0, 17).'..."';
}
return '"'.$default.'"';
}
if (\is_array($default)) {
return '[]';
}
return \var_export($default, true);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion\Source;
use Psy\Completion\CompletionEngine;
use Psy\Completion\CompletionRequest;
use Psy\Readline\Interactive\Helper\CurrentWord;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
/**
* Completion-based suggestion source.
*
* Uses the completion engine to provide context-aware suggestions.
*/
class ContextAwareSource implements SourceInterface
{
private CompletionEngine $completer;
public function __construct(CompletionEngine $completer)
{
$this->completer = $completer;
}
/**
* {@inheritdoc}
*/
public function getSuggestion(string $buffer, int $cursorPosition): ?SuggestionResult
{
$completions = $this->completer->getCompletions(
new CompletionRequest($buffer, $cursorPosition, CompletionRequest::MODE_SUGGESTION)
);
if (empty($completions)) {
return null;
}
$completion = $completions[0];
$currentWord = CurrentWord::extract($buffer, $cursorPosition);
if ($currentWord !== '' && \stripos($completion, $currentWord) === 0) {
$suffix = \substr($completion, \strlen($currentWord));
} else {
$suffix = $completion;
}
return SuggestionResult::forAppend(
$suffix,
SuggestionResult::SOURCE_CONTEXT_AWARE,
$cursorPosition
);
}
/**
* {@inheritdoc}
*/
public function getPriority(): int
{
// High priority, but lower than history (which should be 100)
// We want context-aware to kick in when history doesn't match
return 90;
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion\Source;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
/**
* Provides suggestions from command history.
*
* Searches history for commands that start with the current buffer.
* Returns the most recent matching command.
*/
class HistorySource implements SourceInterface
{
private History $history;
public function __construct(History $history)
{
$this->history = $history;
}
/**
* {@inheritdoc}
*/
public function getSuggestion(string $buffer, int $cursorPosition): ?SuggestionResult
{
// Search history most-recent first.
$entries = $this->history->getAll();
for ($i = \count($entries) - 1; $i >= 0; $i--) {
$entry = $entries[$i];
$line = $entry['command'];
if (\strpos($line, $buffer) === 0 && $line !== $buffer) {
$acceptText = \substr($line, \strlen($buffer));
$displayText = $acceptText;
if ($entry['lines'] > 1) {
$displayText = History::collapseToSingleLine($acceptText);
}
return SuggestionResult::forAppend(
$displayText,
SuggestionResult::SOURCE_HISTORY,
$cursorPosition,
$acceptText
);
}
}
return null;
}
/**
* {@inheritdoc}
*/
public function getPriority(): int
{
return 100;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion\Source;
use Psy\Readline\Interactive\Suggestion\SuggestionResult;
/**
* Interface for suggestion sources.
*
* Sources provide suggestions based on different strategies (history, completion, etc.).
*/
interface SourceInterface
{
/**
* Get suggestion for the given buffer.
*
* The engine calls sources only for non-empty buffers; callers invoking
* sources directly should apply the same precondition.
*/
public function getSuggestion(string $buffer, int $cursorPosition): ?SuggestionResult;
/**
* Get priority of this source (higher = checked first).
*/
public function getPriority(): int;
}

View File

@@ -0,0 +1,202 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion;
use Psy\Readline\Interactive\Helper\DebugLog;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Suggestion\Source\CallSignatureSource;
use Psy\Readline\Interactive\Suggestion\Source\HistorySource;
use Psy\Readline\Interactive\Suggestion\Source\SourceInterface;
/**
* Suggestion engine that coordinates multiple suggestion sources.
*
* Manages caching and selects the best suggestion from available sources.
*/
class SuggestionEngine
{
/** @var SourceInterface[] */
private array $sources = [];
private History $history;
private ?SuggestionResult $lastSuggestion = null;
private string $lastBuffer = '';
private int $lastCursorPosition = -1;
private int $lastHistoryRevision = -1;
private SuggestionFilter $filter;
private bool $frecencyInitialized = false;
public function __construct(History $history)
{
$this->history = $history;
$this->sources = [
new CallSignatureSource(),
new HistorySource($history),
];
$this->sortSources();
$this->filter = new SuggestionFilter();
DebugLog::log('SuggestionEngine', 'INIT', ['sources' => \count($this->sources)]);
}
/**
* Get suggestion for current buffer state.
*
* Only suggests when cursor is at end of buffer.
*
* @param string $buffer Current input buffer
* @param int $cursorPosition Current cursor position
*
* @return SuggestionResult|null
*/
public function getSuggestion(string $buffer, int $cursorPosition): ?SuggestionResult
{
// @todo should this be multi-line aware?
if ($cursorPosition !== \mb_strlen($buffer)) {
DebugLog::log('Suggestion', 'SKIP', ['reason' => 'cursor_not_at_end', 'pos' => $cursorPosition, 'len' => \mb_strlen($buffer)]);
return null;
}
if (\trim($buffer) === '') {
DebugLog::log('Suggestion', 'SKIP', ['reason' => 'empty_buffer']);
return null;
}
$historyRevision = $this->history->getRevision();
if ($historyRevision !== $this->lastHistoryRevision) {
$this->frecencyInitialized = false;
}
if ($buffer === $this->lastBuffer &&
$cursorPosition === $this->lastCursorPosition &&
$historyRevision === $this->lastHistoryRevision &&
$this->lastSuggestion !== null) {
DebugLog::log('Suggestion', 'CACHE_HIT', ['text' => $this->lastSuggestion->getDisplayText(), 'source' => $this->lastSuggestion->getSource()]);
return $this->lastSuggestion;
}
$candidates = [];
foreach ($this->sources as $source) {
$suggestion = $source->getSuggestion($buffer, $cursorPosition);
if ($suggestion !== null && $this->filter->isValid($buffer, $suggestion->getDisplayText())) {
$candidates[] = $suggestion;
DebugLog::log('Suggestion', 'CANDIDATE', ['source' => $suggestion->getSource(), 'text' => $suggestion->getDisplayText()]);
}
}
$best = null;
if (!empty($candidates)) {
$this->ensureFrecencyIndex();
DebugLog::log('SuggestionEngine', 'SCORING', ['candidates' => \count($candidates)]);
$best = $this->selectBestCandidate($buffer, $candidates);
DebugLog::log('Suggestion', 'SHOW', ['text' => $best->getDisplayText(), 'source' => $best->getSource(), 'buffer' => $buffer]);
} else {
DebugLog::log('Suggestion', 'NONE', ['buffer' => $buffer]);
}
$this->lastBuffer = $buffer;
$this->lastCursorPosition = $cursorPosition;
$this->lastHistoryRevision = $historyRevision;
$this->lastSuggestion = $best;
return $best;
}
/**
* Clear cached suggestion.
*/
public function clearCache(): void
{
if ($this->lastSuggestion !== null) {
DebugLog::log('Suggestion', 'CACHE_CLEAR', ['had_suggestion' => true]);
}
$this->lastSuggestion = null;
$this->lastBuffer = '';
$this->lastCursorPosition = -1;
$this->lastHistoryRevision = -1;
}
/**
* Add a suggestion source.
*/
public function addSource(SourceInterface $source): void
{
$this->sources[] = $source;
$this->sortSources();
$this->clearCache();
}
/**
* Sort sources by priority, highest first.
*/
private function sortSources(): void
{
\usort($this->sources, fn (SourceInterface $a, SourceInterface $b) => $b->getPriority() <=> $a->getPriority());
}
/**
* Build and attach the frecency index on first scoring operation.
*/
private function ensureFrecencyIndex(): void
{
if ($this->frecencyInitialized) {
return;
}
$startedAt = \microtime(true);
$frecencyIndex = new FrecencyIndex($this->history);
$this->filter->setFrecencyIndex($frecencyIndex);
$this->frecencyInitialized = true;
DebugLog::log('SuggestionEngine', 'FRECENCY_READY', [
'words' => \count($frecencyIndex->getAllScores()),
'ms' => \sprintf('%.1f', (\microtime(true) - $startedAt) * 1000),
]);
}
/**
* Score candidates once and return the best one.
*
* Keeps source-order as a deterministic tie-breaker.
*
* @param SuggestionResult[] $candidates
*/
private function selectBestCandidate(string $buffer, array $candidates): SuggestionResult
{
$scored = [];
foreach ($candidates as $index => $candidate) {
$scored[] = [
'suggestion' => $candidate,
'score' => $this->filter->score($buffer, $candidate),
'index' => $index,
];
}
\usort($scored, static function (array $a, array $b): int {
if ($a['score'] !== $b['score']) {
return $b['score'] <=> $a['score'];
}
return $a['index'] <=> $b['index'];
});
return $scored[0]['suggestion'];
}
}

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion;
use Psy\Readline\Interactive\Helper\DebugLog;
/**
* Validates and scores suggestions based on PHP context.
*
* Ensures suggestions make sense in the current context.
*/
class SuggestionFilter
{
private ?FrecencyIndex $frecencyIndex = null;
/**
* Set frecency index for importance-based ranking.
*/
public function setFrecencyIndex(FrecencyIndex $index): void
{
$this->frecencyIndex = $index;
}
/**
* Check if suggestion is valid for the given buffer context.
*
* @param string $buffer Current input buffer
* @param string $suggestion Proposed suggestion text
*
* @return bool
*/
public function isValid(string $buffer, string $suggestion): bool
{
if (\trim($suggestion) === '') {
return false;
}
if ($this->isAfterObjectOperator($buffer)) {
return !$this->looksLikeFunction($buffer.$suggestion);
}
return true;
}
/**
* Score a suggestion for ranking purposes.
*
* Higher score = better suggestion.
*/
public function score(string $buffer, SuggestionResult $suggestion): int
{
$score = 50;
$appliedText = $suggestion->applyToBuffer($buffer);
if ($this->frecencyIndex !== null) {
$score += $this->calculateFrecencyBoost($appliedText);
}
$length = \mb_strlen($suggestion->getDisplayText());
if ($length < 10) {
$score += 5;
} elseif ($length > 50) {
$score -= 5;
}
switch ($suggestion->getSource()) {
case SuggestionResult::SOURCE_CALL_SIGNATURE:
$score += 15;
break;
case SuggestionResult::SOURCE_HISTORY:
$score += 10;
break;
}
if ($buffer !== '' && \strpos($appliedText, $buffer) === 0) {
$score += 10;
}
if (\strpos($suggestion->getDisplayText(), "\n") === false) {
$score += 5;
}
$finalScore = (int) \max(0, \min(100, $score));
if (DebugLog::isEnabled()) {
DebugLog::log('SuggestionFilter', 'SCORE', [
'score' => $finalScore,
'source' => $suggestion->getSource(),
'text' => $appliedText,
]);
}
return $finalScore;
}
/**
* Calculate frecency boost for a suggestion.
*
* Extracts important words and sums their frecency scores.
*
* @param string $text full text produced by applying the suggestion
*
* @return float Boost from 0-30
*/
private function calculateFrecencyBoost(string $text): float
{
$words = WordExtractor::extractNormalizedIdentifiers($text);
if (empty($words)) {
return 0.0;
}
$totalScore = 0.0;
foreach ($words as $word) {
$totalScore += $this->frecencyIndex->getScore($word);
}
$avgScore = $totalScore / \count($words);
return \min(30.0, $avgScore / 3.0);
}
/**
* Check if buffer ends with object operator (->).
*/
private function isAfterObjectOperator(string $buffer): bool
{
return (bool) \preg_match('/->\s*[a-zA-Z_]*$/', $buffer);
}
/**
* Check if text looks like a function (ends with parentheses).
*/
private function looksLikeFunction(string $text): bool
{
return (bool) \preg_match('/\([^)]*\)\s*$/', $text);
}
}

View File

@@ -0,0 +1,127 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion;
/**
* Represents a suggestion result from a suggestion source.
*/
class SuggestionResult
{
public const SOURCE_HISTORY = 'history';
public const SOURCE_CALL_SIGNATURE = 'call-signature';
public const SOURCE_CONTEXT_AWARE = 'context-aware';
private string $displayText;
private string $source;
private string $acceptText;
private int $replaceStart;
private int $replaceEnd;
/**
* @param string $displayText Text shown in ghost-text preview
* @param string $source Source type: 'history', 'call-signature', 'context-aware'
* @param string $acceptText Text inserted when accepting the suggestion
* @param int $replaceStart Start offset of the replacement range (inclusive)
* @param int $replaceEnd End offset of the replacement range (exclusive)
*/
public function __construct(string $displayText, string $source, string $acceptText, int $replaceStart, int $replaceEnd)
{
if ($replaceStart < 0 || $replaceEnd < $replaceStart) {
throw new \InvalidArgumentException('Invalid suggestion replacement range.');
}
$this->displayText = $displayText;
$this->source = $source;
$this->acceptText = $acceptText;
$this->replaceStart = $replaceStart;
$this->replaceEnd = $replaceEnd;
}
/**
* Build a suggestion that appends text at the current cursor.
*
* @param string $displayText Text shown in ghost-text preview
* @param string $source Source type
* @param int $cursorPosition Current cursor position
* @param string|null $acceptText Optional accept text (defaults to display text)
*/
public static function forAppend(
string $displayText,
string $source,
int $cursorPosition,
?string $acceptText = null
): self {
$insertText = $acceptText ?? $displayText;
return new self($displayText, $source, $insertText, $cursorPosition, $cursorPosition);
}
/**
* Get the suggestion text shown in the ghost-text preview.
*/
public function getDisplayText(): string
{
return $this->displayText;
}
/**
* Get the source that provided this suggestion.
*/
public function getSource(): string
{
return $this->source;
}
/**
* Get the text inserted when accepting this suggestion.
*/
public function getAcceptText(): string
{
return $this->acceptText;
}
/**
* Replacement range start (inclusive).
*/
public function getReplaceStart(): int
{
return $this->replaceStart;
}
/**
* Replacement range end (exclusive).
*/
public function getReplaceEnd(): int
{
return $this->replaceEnd;
}
/**
* Whether this suggestion appends at the given cursor without replacing.
*/
public function isAppendOnly(int $cursor): bool
{
return $this->replaceStart === $cursor && $this->replaceEnd === $cursor;
}
/**
* Apply the suggestion edit to a buffer string.
*/
public function applyToBuffer(string $buffer): string
{
$length = \mb_strlen($buffer);
$start = \max(0, \min($this->replaceStart, $length));
$end = \max($start, \min($this->replaceEnd, $length));
return \mb_substr($buffer, 0, $start).$this->acceptText.\mb_substr($buffer, $end);
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive\Suggestion;
/**
* Shared identifier extraction for suggestion/frecency logic.
*/
final class WordExtractor
{
/**
* Extract normalized identifier-like words from text.
*
* @return string[]
*/
public static function extractNormalizedIdentifiers(string $text): array
{
$words = [];
\preg_match_all(
'/(?:\$)?[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*/u',
$text,
$matches
);
foreach ($matches[0] as $word) {
$normalized = \strtolower(\ltrim($word, '$'));
if (\strlen($normalized) < 3) {
continue;
}
$words[] = $normalized;
}
return \array_unique($words);
}
}

View File

@@ -0,0 +1,357 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
use Psy\Readline\Interactive\Input\Key;
use Psy\Readline\Interactive\Input\StdinReader;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
use Symfony\Component\Console\Terminal as SymfonyTerminal;
/**
* Terminal abstraction for cursor control and I/O.
*
* Uses Symfony Console components for terminal control, capability detection,
* and themed output formatting.
*/
class Terminal
{
private ?string $originalSttyMode = null;
private StdinReader $input;
private StreamOutput $output;
private bool $bracketedPasteEnabled = false;
private SymfonyTerminal $symfonyTerminal;
private Cursor $cursor;
/**
* Dirty flags let FrameRenderer detect out-of-band writes
* that invalidate its cached frame or cursor position.
*/
private bool $dirty = false;
private bool $cursorRowUnknown = false;
private int $frameRenderDepth = 0;
/**
* Create a new Terminal instance.
*/
public function __construct(StdinReader $input, StreamOutput $output)
{
$this->input = $input;
$this->output = $output;
$this->symfonyTerminal = new SymfonyTerminal();
$this->cursor = new Cursor($output);
}
/**
* Check whether any out-of-band write has occurred since the last frame.
*/
public function isDirty(): bool
{
return $this->dirty;
}
/**
* Check whether cursor row tracking has been invalidated.
*/
public function isCursorRowUnknown(): bool
{
return $this->cursorRowUnknown;
}
/**
* Begin a frame render, clearing dirty flags.
*
* Writes during a frame render don't set the dirty flags.
*/
public function beginFrameRender(): void
{
$this->frameRenderDepth++;
$this->dirty = false;
$this->cursorRowUnknown = false;
}
/**
* End a frame render.
*/
public function endFrameRender(): void
{
$this->frameRenderDepth = \max(0, $this->frameRenderDepth - 1);
}
/**
* Write text to the terminal and flush.
*
* Writes directly to the stream, bypassing the output formatter.
*
* By default, out-of-band writes (outside a frame render) mark the
* frame dirty and check for newlines that would move the cursor row.
* Pass $visible = false for writes that don't affect screen content
* or cursor position (e.g. BEL, mode-change escape sequences).
*/
public function write(string $text, bool $visible = true): void
{
if ($visible) {
$movesRow = \strpbrk($text, "\n\v\f") !== false;
$this->noteOutOfBandWrite($movesRow);
}
$this->output->write($text, false, OutputInterface::OUTPUT_RAW);
}
/**
* Flush the output stream.
*/
public function flush(): void
{
\fflush($this->output->getStream());
}
/**
* Invalidate frame renderer caches before the next render pass.
*
* Useful when a mode switch should repaint the full input frame.
*/
public function invalidateFrame(bool $cursorRowUnknown = false): void
{
$this->dirty = true;
if ($cursorRowUnknown) {
$this->cursorRowUnknown = true;
}
}
/**
* Get the output formatter.
*
* Provides access to the formatter for cases where direct formatting control is needed.
*/
public function getFormatter(): OutputFormatterInterface
{
return $this->output->getFormatter();
}
/**
* Format text using the output formatter.
*
* Applies theme styles like <whisper>, <input_highlight>, etc.
*
* @return string Formatted text with ANSI codes
*/
public function format(string $text): string
{
return $this->getFormatter()->format($text);
}
/**
* Write formatted text to the terminal.
*
* Text is processed through the output formatter, applying theme styles
* like <whisper>, <input_highlight>, etc.
*/
public function writeFormatted(string $text): void
{
$this->write($this->format($text));
}
/**
* Move cursor to a specific column (1-indexed, terminal convention).
*/
public function moveCursorToColumn(int $column): void
{
$this->noteOutOfBandWrite();
$this->cursor->moveToColumn($column);
}
/**
* Move cursor up.
*/
public function moveCursorUp(int $count = 1): void
{
if ($count > 0) {
$this->noteOutOfBandWrite(true);
$this->cursor->moveUp($count);
}
}
/**
* Move cursor down by specified number of lines.
*/
public function moveCursorDown(int $count = 1): void
{
if ($count > 0) {
$this->noteOutOfBandWrite(true);
$this->cursor->moveDown($count);
}
}
/**
* Clear entire current line.
*/
public function clearLine(): void
{
$this->noteOutOfBandWrite();
$this->cursor->clearLine();
}
/**
* Clear from cursor to end of line.
*/
public function clearToEndOfLine(): void
{
$this->noteOutOfBandWrite();
$this->cursor->clearLineAfter();
}
/**
* Clear from cursor to end of screen.
*/
public function clearToEndOfScreen(): void
{
$this->noteOutOfBandWrite();
$this->cursor->clearOutput();
}
/**
* Save cursor position.
*/
public function saveCursor(): void
{
$this->cursor->savePosition();
}
/**
* Restore cursor position.
*/
public function restoreCursor(): void
{
$this->noteOutOfBandWrite(true);
$this->cursor->restorePosition();
}
/**
* Read a single key press from input.
*/
public function readKey(): Key
{
return $this->input->readKey();
}
/**
* Get terminal width in columns.
*/
public function getWidth(): int
{
return $this->symfonyTerminal->getWidth();
}
/**
* Get terminal height in rows.
*/
public function getHeight(): int
{
return $this->symfonyTerminal->getHeight();
}
/**
* Ring the terminal bell.
*/
public function bell(): void
{
$this->write("\x07", false); // BEL character
}
/**
* Enable raw mode for terminal input.
*
* Disables canonical mode, echo, and signal processing so we can
* read characters one at a time and handle control characters.
*/
public function enableRawMode(): bool
{
// Only works on Unix-like systems with stty
if (\DIRECTORY_SEPARATOR === '\\') {
return false;
}
$stty = \shell_exec('stty -g');
if ($stty === null || $stty === false) {
return false;
}
$this->originalSttyMode = \trim($stty);
// Set raw mode: -icanon (disable canonical mode), -echo (disable echo)
// -isig (disable signal chars like Ctrl-C), -ixon (disable Ctrl-S/Q)
\shell_exec('stty -icanon -echo -isig -ixon');
return true;
}
/**
* Disable raw mode and restore original terminal settings.
*/
public function disableRawMode(): void
{
if ($this->originalSttyMode !== null) {
\shell_exec('stty '.\escapeshellarg($this->originalSttyMode));
$this->originalSttyMode = null;
}
}
/**
* Enable bracketed paste mode.
*
* When enabled, pasted text is wrapped with special escape sequences
* (\033[200~ / \033[201~) to distinguish it from typed content.
*/
public function enableBracketedPaste(): void
{
if (!$this->bracketedPasteEnabled) {
$this->write("\033[?2004h", false);
$this->bracketedPasteEnabled = true;
}
}
/**
* Disable bracketed paste mode.
*/
public function disableBracketedPaste(): void
{
if ($this->bracketedPasteEnabled) {
$this->write("\033[?2004l", false);
$this->bracketedPasteEnabled = false;
}
}
/**
* Check if bracketed paste mode is enabled.
*/
public function isBracketedPasteEnabled(): bool
{
return $this->bracketedPasteEnabled;
}
/**
* Flag the terminal as dirty when writing outside a frame render.
*/
private function noteOutOfBandWrite(bool $movesRow = false): void
{
if ($this->frameRenderDepth === 0) {
$this->dirty = true;
if ($movesRow) {
$this->cursorRowUnknown = true;
}
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline\Interactive;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\StreamOutput;
/**
* StreamOutput used exclusively for interactive terminal UI rendering.
*
* This writes to the same underlying stream as the shell output, but bypasses
* ShellOutput-specific behaviors like visible-output listeners.
*/
class TerminalOutput extends StreamOutput
{
private StreamOutput $source;
public function __construct(StreamOutput $source)
{
$this->source = $source;
parent::__construct(
$source->getStream(),
$source->getVerbosity(),
$source->isDecorated(),
$source->getFormatter()
);
}
/**
* {@inheritdoc}
*
* @param string|iterable<string> $messages
*/
public function write($messages, $newline = false, $type = 0): void
{
$this->syncState();
parent::write($messages, $newline, $type);
}
/**
* {@inheritdoc}
*/
public function getFormatter(): OutputFormatterInterface
{
$this->syncState();
return parent::getFormatter();
}
/**
* Keep formatter and display flags aligned with the shell output.
*/
private function syncState(): void
{
parent::setFormatter($this->source->getFormatter());
parent::setDecorated($this->source->isDecorated());
parent::setVerbosity($this->source->getVerbosity());
}
}

View File

@@ -0,0 +1,457 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline;
use Psy\CommandAware;
use Psy\Completion\CompletionEngine;
use Psy\Exception\ThrowUpException;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Helper\DebugLog;
use Psy\Readline\Interactive\Input\History as InteractiveHistory;
use Psy\Readline\Interactive\Input\StdinReader;
use Psy\Readline\Interactive\InteractiveSession;
use Psy\Readline\Interactive\Readline as InternalReadline;
use Psy\Readline\Interactive\Suggestion\Source\ContextAwareSource;
use Psy\Readline\Interactive\Terminal;
use Psy\Readline\Interactive\TerminalOutput;
use Psy\Shell;
use Psy\ShellAware;
use Psy\Util\TerminalColor;
use Psy\Util\Tty;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
/**
* Interactive readline implementation.
*
* A pure-PHP readline with visual feedback, autosuggestions, tab completion,
* and other interactive features.
*/
class InteractiveReadline implements InteractiveReadlineInterface, ShellAware, CommandAware
{
private InternalReadline $readline;
private InteractiveHistory $history;
private Terminal $terminal;
private InteractiveSession $session;
/** @var string|false */
private $historyFile;
/** @var string|false */
private $historyImportFile = false;
private bool $historyFilesResolved = false;
private int $historySize;
private bool $eraseDups;
/**
* Interactive readline is supported if stdin is a TTY.
*
* Interactive readline requires an interactive terminal because it uses
* stty commands for raw mode. When stdin is piped, stty will fail with
* "stdin isn't a terminal" warnings.
*/
public static function isSupported(): bool
{
// Requires Symfony Console 5.1+ for Cursor support
if (!\class_exists('Symfony\Component\Console\Cursor')) {
return false;
}
return Tty::supportsStty();
}
/**
* Interactive readline supports bracketed paste.
*/
public static function supportsBracketedPaste(): bool
{
return true;
}
private bool $booted = false;
/**
* Create an interactive readline instance.
*
* @param string|false|null $historyFile
* @param int $historySize
* @param bool $eraseDups
*/
public function __construct($historyFile = null, $historySize = 0, $eraseDups = false)
{
$this->historyFile = ($historyFile !== null) ? $historyFile : false;
$this->historySize = $historySize;
$this->eraseDups = $eraseDups;
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output, ?Terminal $terminal = null): void
{
if (!($output instanceof StreamOutput)) {
throw new \InvalidArgumentException('InteractiveReadline requires a StreamOutput instance.');
}
DebugLog::enable($output->getVerbosity());
$this->terminal = $terminal ?? new Terminal(new StdinReader(\STDIN), new TerminalOutput($output));
$this->session = new InteractiveSession($this->terminal);
$this->history = new InteractiveHistory($this->historySize, $this->eraseDups);
$this->resolveHistoryFiles();
$this->loadHistory();
$this->readline = new InternalReadline($this->terminal, null, $this->history);
$this->applyDynamicInputFrameColor($output);
$this->booted = true;
if (DebugLog::isEnabled()) {
$this->showDebugInfo();
}
}
/**
* Ensure setOutput has been called before using readline internals.
*
* @throws \RuntimeException if readline has not been booted via setOutput
*/
private function assertBooted(): void
{
if (!$this->booted) {
throw new \RuntimeException('InteractiveReadline has not been booted. Call setOutput() first.');
}
}
/**
* Show debug logging information (whisper message).
*/
private function showDebugInfo(): void
{
$logPath = DebugLog::getLogPath();
DebugLog::separator('INTERACTIVE READLINE SESSION START');
DebugLog::log('System', 'INITIALIZED', ['log_path' => $logPath]);
// Output whisper message to stderr so it doesn't interfere with shell output
$message = \sprintf(
"\033[90mDebug logging enabled: tail -f %s\033[0m\n",
$logPath
);
\fwrite(\STDERR, $message);
}
/**
* {@inheritdoc}
*/
public function addHistory(string $line): bool
{
$this->assertBooted();
$this->history->add($line);
$this->writeHistory();
return true;
}
/**
* {@inheritdoc}
*/
public function clearHistory(): bool
{
$this->assertBooted();
$this->history->clear();
$this->writeHistory();
return true;
}
/**
* {@inheritdoc}
*/
public function listHistory(): array
{
$this->assertBooted();
$entries = $this->history->getAll();
// Extract just the command strings
return \array_map(fn ($entry) => $entry['command'], $entries);
}
/**
* {@inheritdoc}
*/
public function readHistory(): bool
{
$this->assertBooted();
$this->resolveHistoryFiles();
if (!$this->hasReadableHistory()) {
return false;
}
$this->loadHistory();
return true;
}
/**
* {@inheritdoc}
*
* Note: $prompt is unused here, as multi-line buffer rendering is managed internally
*/
public function readline(?string $prompt = null)
{
$this->assertBooted();
try {
$this->session->start();
} catch (\RuntimeException $e) {
throw new ThrowUpException($e);
}
return $this->readline->readline();
}
/**
* {@inheritdoc}
*/
public function setTheme(Theme $theme): void
{
$this->assertBooted();
$this->readline->setTheme($theme);
}
/**
* {@inheritdoc}
*/
public function redisplay()
{
// Interactive readline doesn't expose redisplay (it's handled by the readline itself)
}
/**
* {@inheritdoc}
*/
public function writeHistory(): bool
{
$this->assertBooted();
$this->resolveHistoryFiles();
if ($this->historyFile !== false) {
$this->history->saveToFile($this->historyFile);
}
return true;
}
/**
* {@inheritdoc}
*/
public function setShell(Shell $shell): void
{
$this->assertBooted();
$this->readline->setShell($shell);
}
public function setCommands(array $commands): void
{
$this->assertBooted();
$this->readline->getCommandHighlighter()->setCommands($commands);
}
/**
* {@inheritdoc}
*/
public function setRequireSemicolons(bool $require): void
{
$this->assertBooted();
$this->readline->setRequireSemicolons($require);
}
/**
* {@inheritdoc}
*/
public function setCompletionEngine(CompletionEngine $completionEngine): void
{
$this->assertBooted();
$this->readline->setCompletionEngine($completionEngine);
$suggestionSource = new ContextAwareSource($completionEngine);
$this->readline->getSuggestionEngine()->addSource($suggestionSource);
}
/**
* {@inheritdoc}
*/
public function setOutputWritten(bool $written): void
{
$this->readline->setContinueFrame(!$written);
}
/**
* {@inheritdoc}
*/
public function setUseSuggestions(bool $enabled): void
{
$this->assertBooted();
$this->readline->setUseSuggestions($enabled);
}
/**
* {@inheritdoc}
*/
public function setUseSyntaxHighlighting(bool $enabled): void
{
$this->assertBooted();
$this->readline->setUseSyntaxHighlighting($enabled);
}
/**
* {@inheritdoc}
*/
public function setUseBracketedPaste(bool $enabled): void
{
$this->assertBooted();
$this->session->setUseBracketedPaste($enabled);
}
/**
* {@inheritdoc}
*/
public function getHistory(): InteractiveHistory
{
$this->assertBooted();
return $this->history;
}
/**
* Query the terminal's background color and override the input_frame style.
*
* When detection succeeds, the background is a subtle tint over the
* terminal's actual background, which looks better than the static fallback
* across both dark and light themes.
*/
private function applyDynamicInputFrameColor(StreamOutput $output): void
{
$formatter = $output->getFormatter();
if (!$formatter->isDecorated()) {
return;
}
$this->setDynamicStyle($formatter, 'input_frame', TerminalColor::computeInputFrameBackground());
$this->setDynamicStyle($formatter, 'input_frame_error', TerminalColor::computeInputFrameErrorBackground());
}
/**
* Set a dynamic formatter style from a computed hex color.
*
* Hex colors require Symfony Console 5.2+; falls back gracefully.
*/
private function setDynamicStyle($formatter, string $styleName, ?string $bgHex): void
{
if ($bgHex === null) {
return;
}
try {
$formatter->setStyle($styleName, new OutputFormatterStyle(null, $bgHex));
} catch (\InvalidArgumentException $e) {
// Keep the existing static style
}
}
/**
* Resolve history read/write files.
*
* Legacy plain-text history files are import-only: when one is configured,
* new history is written to a sibling JSONL file.
*/
private function resolveHistoryFiles(): void
{
if ($this->historyFilesResolved || $this->historyFile === false) {
return;
}
$this->historyFilesResolved = true;
if (!$this->isLegacyHistoryFile($this->historyFile)) {
return;
}
$legacyHistoryFile = $this->historyFile;
$jsonlHistoryFile = $legacyHistoryFile.'.jsonl';
if (\file_exists($jsonlHistoryFile)) {
$this->historyFile = $jsonlHistoryFile;
return;
}
$this->historyImportFile = $legacyHistoryFile;
$this->historyFile = $jsonlHistoryFile;
}
/**
* Load history from the configured JSONL file, or import from legacy file.
*/
private function loadHistory(): void
{
if ($this->historyFile !== false && \file_exists($this->historyFile)) {
$this->history->loadFromFile($this->historyFile);
return;
}
if ($this->historyImportFile !== false && \file_exists($this->historyImportFile)) {
$this->history->importFromFile($this->historyImportFile);
}
}
/**
* Check whether either the primary history file or import source exists.
*/
private function hasReadableHistory(): bool
{
if ($this->historyFile !== false && \file_exists($this->historyFile)) {
return true;
}
return $this->historyImportFile !== false && \file_exists($this->historyImportFile);
}
/**
* Check whether a history file is in legacy plain-text readline format.
*
* Peeks at the first non-empty line: if it's a valid JSON history line
* (signature or entry), the file is not legacy; otherwise it is.
*/
private function isLegacyHistoryFile(string $path): bool
{
if (!\file_exists($path) || !\is_readable($path)) {
return false;
}
$handle = @\fopen($path, 'r');
if ($handle === false) {
return false;
}
while (($line = \fgets($handle)) !== false) {
if (\trim($line) === '') {
continue;
}
@\fclose($handle);
return !InteractiveHistory::isJsonHistoryLine($line);
}
@\fclose($handle);
return false;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline;
use Psy\Completion\CompletionEngine;
use Psy\Output\Theme;
use Psy\Readline\Interactive\Input\History;
use Psy\Readline\Interactive\Terminal;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Interface for interactive readline implementations with additional features.
*
* This interface extends the shell readline interface with rich terminal
* behavior such as themed prompts, output-aware redisplay, advanced
* completion, and interactive history integration.
*/
interface InteractiveReadlineInterface extends ShellReadlineInterface
{
/**
* Set the theme (currently used for prompt configuration).
*/
public function setTheme(Theme $theme): void;
/**
* Enable or disable bracketed paste mode.
*/
public function setUseBracketedPaste(bool $enabled): void;
/**
* Enable or disable inline suggestions.
*/
public function setUseSuggestions(bool $enabled): void;
/**
* Enable or disable syntax highlighting.
*/
public function setUseSyntaxHighlighting(bool $enabled): void;
/**
* Set the CompletionEngine for context-aware tab completion and autosuggestions.
*/
public function setCompletionEngine(CompletionEngine $completionEngine): void;
/**
* Set the output stream.
*/
public function setOutput(OutputInterface $output, ?Terminal $terminal = null): void;
/**
* Get the history instance.
*/
public function getHistory(): History;
/**
* Report whether visible output was written since the last input.
*
* The readline implementation uses this to decide whether to continue
* the current input frame or start a fresh one.
*/
public function setOutputWritten(bool $written): void;
}

View File

@@ -0,0 +1,240 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline;
use Psy\CodeAnalysis\BufferAnalyzer;
use Psy\Readline\Interactive\Input\StatementCompletenessPolicy;
use Psy\Shell;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Decorates a legacy physical-line readline and returns logical statements.
*/
class LegacyReadline implements ShellReadlineInterface
{
private Readline $readline;
private ?Shell $shell = null;
private ?OutputInterface $output = null;
private bool $requireSemicolons = false;
private ?string $bufferPrompt = null;
private BufferAnalyzer $bufferAnalyzer;
private StatementCompletenessPolicy $statementCompletenessPolicy;
private array $buffer = [];
/**
* @param mixed $readline A decorated readline instance
*/
public function __construct($readline = null, $historySize = 0, $eraseDups = false)
{
if (!($readline instanceof Readline)) {
throw new \InvalidArgumentException('LegacyReadline requires a decorated Readline instance.');
}
$this->readline = $readline;
$this->bufferAnalyzer = new BufferAnalyzer();
$this->statementCompletenessPolicy = new StatementCompletenessPolicy($this->bufferAnalyzer);
}
public static function isSupported(): bool
{
return true;
}
public static function supportsBracketedPaste(): bool
{
return false;
}
public function addHistory(string $line): bool
{
return $this->readline->addHistory($line);
}
public function clearHistory(): bool
{
return $this->readline->clearHistory();
}
public function listHistory(): array
{
return $this->readline->listHistory();
}
public function readHistory(): bool
{
return $this->readline->readHistory();
}
public function readline(?string $prompt = null)
{
$lines = $this->buffer;
if ($lines !== []) {
$text = \implode("\n", $lines);
if ($this->statementCompletenessPolicy->isCompleteStatement($text)) {
$this->clearBuffer();
return $text;
}
}
while (true) {
$linePrompt = ($lines === []) ? $prompt : ($this->bufferPrompt ?? $prompt);
$line = $this->readline->readline($linePrompt);
if ($line === false) {
if ($lines === []) {
return false;
}
// Mirror the legacy shell behavior for Ctrl+D mid-buffer:
// clear the partial statement and return to a fresh prompt.
$this->clearBuffer();
if ($this->output !== null) {
$this->output->writeln('');
}
return '';
}
if ($lines !== [] && $this->isCommand($line) && !$this->inputInOpenStringOrComment($line)) {
return $line;
}
[$line, $keepBufferOpen] = $this->normalizeLine($line);
$lines[] = $line;
$this->buffer = $lines;
$text = \implode("\n", $lines);
if (!$keepBufferOpen && $this->statementCompletenessPolicy->isCompleteStatement($text)) {
$this->clearBuffer();
return $text;
}
}
}
public function redisplay()
{
$this->readline->redisplay();
}
public function writeHistory(): bool
{
return $this->readline->writeHistory();
}
public function setRequireSemicolons(bool $require): void
{
$this->requireSemicolons = $require;
$this->statementCompletenessPolicy = new StatementCompletenessPolicy(
$this->bufferAnalyzer,
$this->requireSemicolons
);
}
public function setBufferPrompt(?string $prompt): void
{
$this->bufferPrompt = $prompt;
}
/**
* Set the shell output for buffer-clearing notifications.
*/
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
/**
* Set the shell instance for command detection while buffering input.
*/
public function setShell(Shell $shell): void
{
$this->shell = $shell;
}
/**
* Get the buffered physical lines for the current incomplete statement.
*
* @return string[]
*/
public function getBuffer(): array
{
return $this->buffer;
}
/**
* Check whether there is buffered multiline input.
*/
public function hasBuffer(): bool
{
return $this->buffer !== [];
}
/**
* Append generated code to the active multiline buffer.
*/
public function append(string $code): void
{
foreach (\explode("\n", $code) as $line) {
$this->buffer[] = $line;
}
}
/**
* Clear the current incomplete statement buffer.
*/
public function clearBuffer(): void
{
$this->buffer = [];
}
/**
* Strip the legacy trailing backslash continuation marker.
*/
private function normalizeLine(string $line): array
{
$trimmed = \rtrim($line);
if (\substr($trimmed, -1) === '\\') {
return [\substr($trimmed, 0, -1), true];
}
return [$line, false];
}
/**
* Check if the current physical line is a PsySH command.
*/
private function isCommand(string $input): bool
{
if ($this->shell === null) {
return false;
}
return $this->shell->hasCommand($input);
}
/**
* Check whether the current buffer plus input is in an open string or comment.
*/
private function inputInOpenStringOrComment(string $input): bool
{
if ($this->buffer === []) {
return false;
}
$code = $this->buffer;
$code[] = $input;
return $this->bufferAnalyzer->analyze(\implode("\n", $code))->endsInOpenStringOrComment();
}
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline;
/**
* ReadlineAware interface.
*
* This interface is used to pass the Shell's readline implementation into
* commands which depend on it.
*/
interface ReadlineAware
{
/**
* Set the Readline service.
*/
public function setReadline(Readline $readline);
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\Readline;
use Psy\ShellAware;
/**
* Narrow shell-facing readline contract shared by supported readline adapters.
*/
interface ShellReadlineInterface extends Readline, ShellAware
{
/**
* Set whether to require semicolons on all statements.
*/
public function setRequireSemicolons(bool $require): void;
}

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.

View File

@@ -3,7 +3,7 @@
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
* (c) 2012-2026 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.