update lock clucknut
All checks were successful
All checks were successful
This commit is contained in:
@@ -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.
|
||||
|
||||
48
vendor/psy/psysh/src/Readline/Interactive/Actions/AcceptSuggestionAction.php
vendored
Normal file
48
vendor/psy/psysh/src/Readline/Interactive/Actions/AcceptSuggestionAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
59
vendor/psy/psysh/src/Readline/Interactive/Actions/AcceptSuggestionWordAction.php
vendored
Normal file
59
vendor/psy/psysh/src/Readline/Interactive/Actions/AcceptSuggestionWordAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
38
vendor/psy/psysh/src/Readline/Interactive/Actions/ActionInterface.php
vendored
Normal file
38
vendor/psy/psysh/src/Readline/Interactive/Actions/ActionInterface.php
vendored
Normal 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;
|
||||
}
|
||||
82
vendor/psy/psysh/src/Readline/Interactive/Actions/ClearBufferAction.php
vendored
Normal file
82
vendor/psy/psysh/src/Readline/Interactive/Actions/ClearBufferAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
44
vendor/psy/psysh/src/Readline/Interactive/Actions/ClearScreenAction.php
vendored
Normal file
44
vendor/psy/psysh/src/Readline/Interactive/Actions/ClearScreenAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
74
vendor/psy/psysh/src/Readline/Interactive/Actions/DedentLeadingIndentationAction.php
vendored
Normal file
74
vendor/psy/psysh/src/Readline/Interactive/Actions/DedentLeadingIndentationAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteBackwardCharAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteBackwardCharAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
46
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteBracketPairAction.php
vendored
Normal file
46
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteBracketPairAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteForwardAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/DeleteForwardAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
45
vendor/psy/psysh/src/Readline/Interactive/Actions/ExitIfEmptyAction.php
vendored
Normal file
45
vendor/psy/psysh/src/Readline/Interactive/Actions/ExitIfEmptyAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
57
vendor/psy/psysh/src/Readline/Interactive/Actions/ExpandHistoryOnTabAction.php
vendored
Normal file
57
vendor/psy/psysh/src/Readline/Interactive/Actions/ExpandHistoryOnTabAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
68
vendor/psy/psysh/src/Readline/Interactive/Actions/FallbackAction.php
vendored
Normal file
68
vendor/psy/psysh/src/Readline/Interactive/Actions/FallbackAction.php
vendored
Normal 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)).')';
|
||||
}
|
||||
}
|
||||
366
vendor/psy/psysh/src/Readline/Interactive/Actions/HistoryExpansionAction.php
vendored
Normal file
366
vendor/psy/psysh/src/Readline/Interactive/Actions/HistoryExpansionAction.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
86
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertCloseBracketAction.php
vendored
Normal file
86
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertCloseBracketAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
53
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertIndentOnTabAction.php
vendored
Normal file
53
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertIndentOnTabAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
61
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakAction.php
vendored
Normal file
61
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
62
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakOnIncompleteStatementAction.php
vendored
Normal file
62
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakOnIncompleteStatementAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
57
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakOnUnclosedBracketsAction.php
vendored
Normal file
57
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertLineBreakOnUnclosedBracketsAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
99
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertOpenBracketAction.php
vendored
Normal file
99
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertOpenBracketAction.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
59
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertQuoteAction.php
vendored
Normal file
59
vendor/psy/psysh/src/Readline/Interactive/Actions/InsertQuoteAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillLineAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillLineAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
44
vendor/psy/psysh/src/Readline/Interactive/Actions/KillTokenAction.php
vendored
Normal file
44
vendor/psy/psysh/src/Readline/Interactive/Actions/KillTokenAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillWholeLineAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillWholeLineAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillWordAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/KillWordAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveLeftAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveLeftAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveRightAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveRightAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveToEndAction.php
vendored
Normal file
40
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveToEndAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
51
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveToStartAction.php
vendored
Normal file
51
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveToStartAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
45
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveTokenLeftAction.php
vendored
Normal file
45
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveTokenLeftAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
45
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveTokenRightAction.php
vendored
Normal file
45
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveTokenRightAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
41
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveWordLeftAction.php
vendored
Normal file
41
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveWordLeftAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
41
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveWordRightAction.php
vendored
Normal file
41
vendor/psy/psysh/src/Readline/Interactive/Actions/MoveWordRightAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
77
vendor/psy/psysh/src/Readline/Interactive/Actions/NextHistoryAction.php
vendored
Normal file
77
vendor/psy/psysh/src/Readline/Interactive/Actions/NextHistoryAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
86
vendor/psy/psysh/src/Readline/Interactive/Actions/PreviousHistoryAction.php
vendored
Normal file
86
vendor/psy/psysh/src/Readline/Interactive/Actions/PreviousHistoryAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
84
vendor/psy/psysh/src/Readline/Interactive/Actions/RejectSyntaxErrorAction.php
vendored
Normal file
84
vendor/psy/psysh/src/Readline/Interactive/Actions/RejectSyntaxErrorAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
54
vendor/psy/psysh/src/Readline/Interactive/Actions/ReverseSearchAction.php
vendored
Normal file
54
vendor/psy/psysh/src/Readline/Interactive/Actions/ReverseSearchAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
50
vendor/psy/psysh/src/Readline/Interactive/Actions/SelfInsertAction.php
vendored
Normal file
50
vendor/psy/psysh/src/Readline/Interactive/Actions/SelfInsertAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
49
vendor/psy/psysh/src/Readline/Interactive/Actions/SubmitLineAction.php
vendored
Normal file
49
vendor/psy/psysh/src/Readline/Interactive/Actions/SubmitLineAction.php
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
546
vendor/psy/psysh/src/Readline/Interactive/Actions/TabAction.php
vendored
Normal file
546
vendor/psy/psysh/src/Readline/Interactive/Actions/TabAction.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
134
vendor/psy/psysh/src/Readline/Interactive/Helper/ArgumentExtractorVisitor.php
vendored
Normal file
134
vendor/psy/psysh/src/Readline/Interactive/Helper/ArgumentExtractorVisitor.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
221
vendor/psy/psysh/src/Readline/Interactive/Helper/BracketPair.php
vendored
Normal file
221
vendor/psy/psysh/src/Readline/Interactive/Helper/BracketPair.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
382
vendor/psy/psysh/src/Readline/Interactive/Helper/CommandHighlighter.php
vendored
Normal file
382
vendor/psy/psysh/src/Readline/Interactive/Helper/CommandHighlighter.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
218
vendor/psy/psysh/src/Readline/Interactive/Helper/CompletionRenderer.php
vendored
Normal file
218
vendor/psy/psysh/src/Readline/Interactive/Helper/CompletionRenderer.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
52
vendor/psy/psysh/src/Readline/Interactive/Helper/CurrentWord.php
vendored
Normal file
52
vendor/psy/psysh/src/Readline/Interactive/Helper/CurrentWord.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
158
vendor/psy/psysh/src/Readline/Interactive/Helper/DebugLog.php
vendored
Normal file
158
vendor/psy/psysh/src/Readline/Interactive/Helper/DebugLog.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
127
vendor/psy/psysh/src/Readline/Interactive/Helper/HistorySearchRenderer.php
vendored
Normal file
127
vendor/psy/psysh/src/Readline/Interactive/Helper/HistorySearchRenderer.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
63
vendor/psy/psysh/src/Readline/Interactive/Helper/TokenHelper.php
vendored
Normal file
63
vendor/psy/psysh/src/Readline/Interactive/Helper/TokenHelper.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
433
vendor/psy/psysh/src/Readline/Interactive/HistorySearch.php
vendored
Normal file
433
vendor/psy/psysh/src/Readline/Interactive/HistorySearch.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
883
vendor/psy/psysh/src/Readline/Interactive/Input/Buffer.php
vendored
Normal file
883
vendor/psy/psysh/src/Readline/Interactive/Input/Buffer.php
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
575
vendor/psy/psysh/src/Readline/Interactive/Input/History.php
vendored
Normal file
575
vendor/psy/psysh/src/Readline/Interactive/Input/History.php
vendored
Normal 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++;
|
||||
}
|
||||
}
|
||||
247
vendor/psy/psysh/src/Readline/Interactive/Input/IndentationPolicy.php
vendored
Normal file
247
vendor/psy/psysh/src/Readline/Interactive/Input/IndentationPolicy.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
50
vendor/psy/psysh/src/Readline/Interactive/Input/InputQueue.php
vendored
Normal file
50
vendor/psy/psysh/src/Readline/Interactive/Input/InputQueue.php
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
130
vendor/psy/psysh/src/Readline/Interactive/Input/Key.php
vendored
Normal file
130
vendor/psy/psysh/src/Readline/Interactive/Input/Key.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
185
vendor/psy/psysh/src/Readline/Interactive/Input/KeyBindings.php
vendored
Normal file
185
vendor/psy/psysh/src/Readline/Interactive/Input/KeyBindings.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
125
vendor/psy/psysh/src/Readline/Interactive/Input/StatementCompletenessPolicy.php
vendored
Normal file
125
vendor/psy/psysh/src/Readline/Interactive/Input/StatementCompletenessPolicy.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
292
vendor/psy/psysh/src/Readline/Interactive/Input/StdinReader.php
vendored
Normal file
292
vendor/psy/psysh/src/Readline/Interactive/Input/StdinReader.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
99
vendor/psy/psysh/src/Readline/Interactive/Input/TokenNavigationPolicy.php
vendored
Normal file
99
vendor/psy/psysh/src/Readline/Interactive/Input/TokenNavigationPolicy.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
78
vendor/psy/psysh/src/Readline/Interactive/Input/VisualNavigationPolicy.php
vendored
Normal file
78
vendor/psy/psysh/src/Readline/Interactive/Input/VisualNavigationPolicy.php
vendored
Normal 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];
|
||||
}
|
||||
}
|
||||
46
vendor/psy/psysh/src/Readline/Interactive/Input/WordNavigationPolicy.php
vendored
Normal file
46
vendor/psy/psysh/src/Readline/Interactive/Input/WordNavigationPolicy.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
115
vendor/psy/psysh/src/Readline/Interactive/InteractiveSession.php
vendored
Normal file
115
vendor/psy/psysh/src/Readline/Interactive/InteractiveSession.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
149
vendor/psy/psysh/src/Readline/Interactive/Layout/DisplayString.php
vendored
Normal file
149
vendor/psy/psysh/src/Readline/Interactive/Layout/DisplayString.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
62
vendor/psy/psysh/src/Readline/Interactive/Layout/SoftWrapCalculator.php
vendored
Normal file
62
vendor/psy/psysh/src/Readline/Interactive/Layout/SoftWrapCalculator.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
601
vendor/psy/psysh/src/Readline/Interactive/Readline.php
vendored
Normal file
601
vendor/psy/psysh/src/Readline/Interactive/Readline.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
646
vendor/psy/psysh/src/Readline/Interactive/Renderer/FrameRenderer.php
vendored
Normal file
646
vendor/psy/psysh/src/Readline/Interactive/Renderer/FrameRenderer.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
67
vendor/psy/psysh/src/Readline/Interactive/Renderer/OverlayViewport.php
vendored
Normal file
67
vendor/psy/psysh/src/Readline/Interactive/Renderer/OverlayViewport.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
179
vendor/psy/psysh/src/Readline/Interactive/Suggestion/FrecencyIndex.php
vendored
Normal file
179
vendor/psy/psysh/src/Readline/Interactive/Suggestion/FrecencyIndex.php
vendored
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
155
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/CallSignatureSource.php
vendored
Normal file
155
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/CallSignatureSource.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
71
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/ContextAwareSource.php
vendored
Normal file
71
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/ContextAwareSource.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
69
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/HistorySource.php
vendored
Normal file
69
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/HistorySource.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
35
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/SourceInterface.php
vendored
Normal file
35
vendor/psy/psysh/src/Readline/Interactive/Suggestion/Source/SourceInterface.php
vendored
Normal 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;
|
||||
}
|
||||
202
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionEngine.php
vendored
Normal file
202
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionEngine.php
vendored
Normal 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'];
|
||||
}
|
||||
}
|
||||
147
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionFilter.php
vendored
Normal file
147
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionFilter.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
127
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionResult.php
vendored
Normal file
127
vendor/psy/psysh/src/Readline/Interactive/Suggestion/SuggestionResult.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
46
vendor/psy/psysh/src/Readline/Interactive/Suggestion/WordExtractor.php
vendored
Normal file
46
vendor/psy/psysh/src/Readline/Interactive/Suggestion/WordExtractor.php
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
357
vendor/psy/psysh/src/Readline/Interactive/Terminal.php
vendored
Normal file
357
vendor/psy/psysh/src/Readline/Interactive/Terminal.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
vendor/psy/psysh/src/Readline/Interactive/TerminalOutput.php
vendored
Normal file
70
vendor/psy/psysh/src/Readline/Interactive/TerminalOutput.php
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
457
vendor/psy/psysh/src/Readline/InteractiveReadline.php
vendored
Normal file
457
vendor/psy/psysh/src/Readline/InteractiveReadline.php
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
71
vendor/psy/psysh/src/Readline/InteractiveReadlineInterface.php
vendored
Normal file
71
vendor/psy/psysh/src/Readline/InteractiveReadlineInterface.php
vendored
Normal 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;
|
||||
}
|
||||
240
vendor/psy/psysh/src/Readline/LegacyReadline.php
vendored
Normal file
240
vendor/psy/psysh/src/Readline/LegacyReadline.php
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
2
vendor/psy/psysh/src/Readline/Libedit.php
vendored
2
vendor/psy/psysh/src/Readline/Libedit.php
vendored
@@ -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.
|
||||
|
||||
2
vendor/psy/psysh/src/Readline/Readline.php
vendored
2
vendor/psy/psysh/src/Readline/Readline.php
vendored
@@ -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.
|
||||
|
||||
26
vendor/psy/psysh/src/Readline/ReadlineAware.php
vendored
Normal file
26
vendor/psy/psysh/src/Readline/ReadlineAware.php
vendored
Normal 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);
|
||||
}
|
||||
25
vendor/psy/psysh/src/Readline/ShellReadlineInterface.php
vendored
Normal file
25
vendor/psy/psysh/src/Readline/ShellReadlineInterface.php
vendored
Normal 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;
|
||||
}
|
||||
2
vendor/psy/psysh/src/Readline/Transient.php
vendored
2
vendor/psy/psysh/src/Readline/Transient.php
vendored
@@ -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.
|
||||
|
||||
2
vendor/psy/psysh/src/Readline/Userland.php
vendored
2
vendor/psy/psysh/src/Readline/Userland.php
vendored
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user