diff --git a/.gitignore b/.gitignore index a74c7b6..4cc2457 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ Thumbs.db *.swo /vendor/ -/tapper.sock diff --git a/AGENTS.md b/AGENTS.md index ee2ba04..8a5a5b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ src/ Support/SpanTruncator.php Clips/windows Span[] to a fixed width for fixed-width panes (Details, LogItem) docs/ Deeper documentation — see docs/README.md examples/BasicExample.php Runnable demo of the tp() API -tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php, SpanTruncatorTest.php — only test suites that currently exist +tests/Unit/ Unit tests for pure/isolated logic (Scroll, SpanTruncator, AppState, EventBus, MessageFormatter, PhpHighlighter, Rpc/*, SocketPath, LogPath) — Console/Components, Server, and Application have no tests yet (need the ReactPHP loop/php-tui rendering) ``` ## Running things diff --git a/bin/tapper b/bin/tapper index 2546e90..c1ce6dd 100755 --- a/bin/tapper +++ b/bin/tapper @@ -5,10 +5,13 @@ declare(strict_types=1); require __DIR__.'/../vendor/autoload.php'; +$options = getopt('', ['port::']); +$port = isset($options['port']) ? (int) $options['port'] : null; + $app = (require_once __DIR__.'/../src/Console/main.php'); try { - $app->run(); + $app->run($port); } catch (\Throwable $e) { $app->close(); \Tapper\Console\ErrorHandler::logThrowable($e); diff --git a/composer.json b/composer.json index a5c921f..2185bfc 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ } ], "require": { + "php": "^8.2", "react/event-loop": "^1.5", "php-tui/php-tui": "^0.2.1", "react/socket": "^1.16", diff --git a/docs/known-issues.md b/docs/known-issues.md index 0140931..7b0e64a 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -10,7 +10,7 @@ This doc consolidates everything found during a full-codebase review (2026-08-15 Verified with an isolated smoke test (no terminal required): booted `Server` directly against a real `AppState`/`EventBus`, confirmed the socket file appears at `SocketPath::resolve()`, and confirmed `tp()` from a separate process round-trips successfully (`result: ok`) instead of throwing `[Tapper] server not responding.`. -Note: `SocketPath::resolve()` still resolves to "the installed package's own directory" (`realpath(__DIR__.'/..')`), same semantics as the original `Server.php` code — when Tapper is installed as a dependency, that's `vendor/tapperphp/tapper/tapper.sock`, not the consuming project's root. That's fine functionally (both processes resolve the same install, so they agree), but if this ever needs to be more conventional (e.g. `sys_get_temp_dir()`-based, to avoid touching `vendor/`), that's a deliberate follow-up, not part of this fix. +~~Note: `SocketPath::resolve()` still resolves to "the installed package's own directory"~~ — fixed 2026-08-16: `SocketPath::resolve()` and `LogPath::resolve()` now resolve to `sys_get_temp_dir().'/tapper.sock'` / `'/tapper.log'` instead of `realpath(__DIR__.'/..')`. This was flagged as a deliberate follow-up above, and became a real requirement once native packaging entered the picture — a static-php-cli/phpmicro build (`docs/architecture.md` roadmap) embeds `src/` inside a read-only Phar, so a path computed from `__DIR__` would point at a virtual filesystem that can't hold a socket file or an appended-to log file. `sys_get_temp_dir()` is always a real, writable directory regardless of how the app is packaged. Trade-off: this drops the previous per-install isolation (two different projects each requiring `tapperphp/tapper` from their own `vendor/` used to get distinct socket paths for free, since `__DIR__` differed per install) — now every local Tapper session shares `/tapper.sock`, so only one debuggee/TUI pair should run at a time per machine. Revisit if that turns out to matter in practice (e.g. namespace the filename by project directory hash). ### ~~`wait()` listeners on `EventBus` are never cleaned up~~ — fixed 2026-08-15 @@ -45,6 +45,14 @@ Reported case: pressing Space in `LogList` with zero log entries hit `LogList::s Note: this intentionally does *not* route the "check the logs" notice through `Windows/Popup.php` (a full-screen modal isn't the right shape for a transient 5-second notice); the banner lives in `Header` via two new `AppState` fields (`errorNotice`, `errorNoticeExpiresAt`) instead. +### ~~Blocking: typed class constants (PHP 8.3+ syntax) broke every PHP 8.2 install despite README/CI claiming 8.2 support~~ — fixed 2026-08-16 + +`private const string FOO = '...';`-style *typed* class constants are a PHP 8.3 feature — on PHP 8.2 the parser throws `ParseError: syntax error, unexpected identifier "FOO", expecting "="` on the file, at include time, regardless of whether the constant is ever read. This pattern was scattered across most of `Console/*` (`Application`, `Component`, `ErrorHandler`, `Palette`, `MessageFormatter`, `PhpHighlighter`, `Components/{Splash,Details,LogItem}`, `Windows/Main`) — i.e. it would have broken `bin/tapper` for any PHP 8.2 user the moment a log rendered (`MessageFormatter`/`Palette` are on the hot render path for every list row). + +It went undetected by CI (which does run PHP 8.2 in the matrix) purely because the old, minimal test suite (`Scroll`, `ScrollbarRenderer`, `SpanTruncator`) never autoloaded any of the affected classes — PHP never parsed the offending files under 8.2, so there was nothing to fail on. It surfaced the moment new tests started exercising `MessageFormatter`/`PhpHighlighter` directly. Fixed by dropping the type annotation from every class constant repo-wide (behavior is identical, `const FOO = '...'` works on 8.2–8.4); `composer.json` also had no `"php"` constraint at all, so `composer require`d installs wouldn't have been warned either — added `"php": "^8.2"` to `require` to match what the README already promises. + +This is a strong argument for the still-open Console/Components/Server/Application test-coverage gap noted elsewhere in this doc: coverage that's too thin doesn't just miss logic bugs, it can hide the codebase not even parsing on a supported PHP version. + ## Incomplete abstractions (finish or remove, don't extend as-is) - **`Rpc\JsonRpc` interface / `JsonRpcResult` / `JsonRpcError`** — `JsonRpcResult.php` and `JsonRpcError.php` are empty files; `JsonRpc`'s encode/parse methods exist only as commented-out code; `Server.php` builds raw arrays instead of using these types. See `rpc-protocol.md`. diff --git a/docs/rpc-protocol.md b/docs/rpc-protocol.md index 85e89e1..8fceb95 100644 --- a/docs/rpc-protocol.md +++ b/docs/rpc-protocol.md @@ -4,21 +4,12 @@ This documents what is **actually implemented** between a debuggee process (`Run ## Transport -- Unix domain socket, path `/tapper.sock` (computed by `Server.php` via `realpath(__DIR__.'/..')` from inside the installed package). +Two transports, selected by whether a port is configured — there is no separate mode flag: + +- **Unix domain socket (default)**, path `sys_get_temp_dir().'/tapper.sock'`, resolved by `Tapper\SocketPath::resolve()` (`src/SocketPath.php`). Used whenever no port is given: `bin/tapper` without `--port`, and `Runtime\Tapper` when the `TAPPER_PORT` env var is unset/empty. +- **TCP, `127.0.0.1:`** — `bin/tapper --port=2138` binds `Server` there instead of the socket; `TAPPER_PORT=2138` makes `Runtime\Tapper` connect there instead. Both sides must agree on the same port. `AppState::$port` is `null` in socket mode (Header hides the "port: N" segment) and holds the configured port in TCP mode (Header shows it). - One line of JSON per message, `\n`-terminated, decoded with `clue/ndjson-react` on the server side. - The client (`Rpc/JsonRpcClient.php`) is a **blocking** `stream_socket_client()` — connect timeout 0.5s by default, then `stream_set_timeout()` + blocking `fgets()` for the reply, using a separate `pauseTimeout` (default 3600s) so a `wait()` call can block for up to an hour waiting for the user to press Enter in the TUI. -- TCP is not currently wired up despite `JsonRpcClient` having `$host`/`$port` constructor properties — those fields are dead; the actual connection target is a hardcoded string, see the bug below. - -## Socket path resolution - -Both sides resolve the socket path through one shared helper, `Tapper\SocketPath::resolve()` (`src/SocketPath.php`): - -```php -$projectPath = realpath(__DIR__.'/..'); // src/ -> package root -return $projectPath.'/tapper.sock'; -``` - -`Server.php` calls it to bind the listener; `Rpc/JsonRpcClient.php` calls it to connect. Because both sides call the same function instead of computing the path independently, they can't diverge. (This previously *did* diverge — `JsonRpcClient` had a hardcoded absolute path to one developer's machine, fixed 2026-08-15; see `known-issues.md` for the history if you're wondering why this indirection exists instead of a literal path.) ## Request shape (client → server) @@ -33,7 +24,7 @@ Built by `Rpc\JsonRpcRequest::payload()`: } ``` -- `id` is generated by `uniqid('rpc_', true)` on every call — `JsonRpcRequest` has no way to accept an explicit id (the constructor doesn't take one; `payload()` references an undefined `$id` variable that null-coalesces to always calling `uniqid()` — works, but is dead/misleading code, not an intentional "reuse an id" feature). +- `id` defaults to `uniqid('rpc_', true)` when the caller doesn't supply one — `JsonRpcRequest`'s constructor takes an optional `?string $id`, and `payload()` reads `$this->id ?? uniqid('rpc_', true)`. - Two methods are implemented server-side: `log` and `wait`. ### `log` params (sent by every plain `tp($value)`) diff --git a/src/Console/Application.php b/src/Console/Application.php index e7c48cd..3a014a8 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -26,9 +26,9 @@ class Application { - const float RESIZE_RATE = 1 / 4; + const RESIZE_RATE = 1 / 4; - const float RENDER_RATE = 1 / 60; + const RENDER_RATE = 1 / 60; private Component $window; @@ -53,12 +53,13 @@ public function __construct( private Server $server, ) {} - public function run(): int + public function run(?int $port = null): int { ErrorHandler::install($this->appState); $this->area = $this->phpTermBackend->size(); $this->appState->version = 'v0.1.1'; + $this->appState->port = $port; $this->terminal->execute(Actions::alternateScreenEnable()); $this->terminal->execute(Actions::cursorHide()); $this->terminal->execute(Actions::enableMouseCapture()); @@ -68,7 +69,7 @@ public function run(): int $this->init(); $this->startRendering(); $this->startInputHandling(); - $this->server->run(); + $this->server->run($port); $this->loop->addSignal(SIGINT, function () { $this->close(); diff --git a/src/Console/Component.php b/src/Console/Component.php index 5b39f14..db0d2cc 100644 --- a/src/Console/Component.php +++ b/src/Console/Component.php @@ -20,9 +20,9 @@ abstract class Component { - const string BEFORE_INIT = 'beforeInit'; + const BEFORE_INIT = 'beforeInit'; - const string AFTER_INIT = 'afterInit'; + const AFTER_INIT = 'afterInit'; protected array $components = []; diff --git a/src/Console/Components/Details.php b/src/Console/Components/Details.php index 8c84131..f638e03 100644 --- a/src/Console/Components/Details.php +++ b/src/Console/Components/Details.php @@ -44,11 +44,11 @@ class Details extends Component // 1 row top/bottom. Every width/height used to lay out content has to account for // that, since BlockRenderer only insets the *rendering*, not values computed here // beforehand (truncation budgets, paging math). - private const int BORDER_SIZE = 2; + private const BORDER_SIZE = 2; - private const int SCROLLBAR_GUTTER = 1; + private const SCROLLBAR_GUTTER = 1; - private const int H_STEP = 4; + private const H_STEP = 4; private int $count = 0; diff --git a/src/Console/Components/Header.php b/src/Console/Components/Header.php index 13e25f4..2934cef 100644 --- a/src/Console/Components/Header.php +++ b/src/Console/Components/Header.php @@ -51,8 +51,9 @@ protected function view(Area $area): Widget Span::fromString(' '), Span::fromString($label), $unread ? Span::fromString(sprintf(' (↓%s)', $this->appState->unread))->yellow() : Span::fromString(''), - Span::fromString(' | '), - Span::fromString(sprintf('port: %s', $this->appState->port)), + $this->appState->port !== null + ? Span::fromString(sprintf(' | port: %s', $this->appState->port)) + : Span::fromString(''), $this->appState->filter !== '' && ! $this->appState->typingMode ? Span::styled(sprintf(' | filter: %s', $this->appState->filter), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))) : Span::fromString(''), diff --git a/src/Console/Components/LogItem.php b/src/Console/Components/LogItem.php index f982b42..dcd20a4 100644 --- a/src/Console/Components/LogItem.php +++ b/src/Console/Components/LogItem.php @@ -25,9 +25,9 @@ class LogItem extends Component { - public const int HEIGHT = 2; + public const HEIGHT = 2; - private const int SCROLLBAR_GUTTER = 1; + private const SCROLLBAR_GUTTER = 1; private ?LogItemState $log = null; @@ -48,14 +48,14 @@ public function mouseMove(array $data): void /** @var MouseEvent $event */ $event = $data['event']; - if (! $this->log || $this->index === null) { + if (! $this->log || $this->index === null || $this->area === null) { return; } $elementPosInView = ($this->index - $this->appState->offset); - $itemPosition = ($elementPosInView * self::HEIGHT) + 1; + $itemPosition = $this->area->top() + ($elementPosInView * self::HEIGHT); - if ($event->row > $itemPosition + if ($event->row >= $itemPosition && $event->row < $itemPosition + self::HEIGHT ) { $this->click(); diff --git a/src/Console/Components/LogList.php b/src/Console/Components/LogList.php index 6708592..de28317 100644 --- a/src/Console/Components/LogList.php +++ b/src/Console/Components/LogList.php @@ -7,13 +7,15 @@ use PhpTui\Term\KeyCode; use PhpTui\Term\KeyModifiers; use PhpTui\Term\MouseEventKind; +use PhpTui\Tui\Color\AnsiColor; use PhpTui\Tui\Display\Area; +use PhpTui\Tui\Extension\Core\Widget\Buffer\BufferContext; +use PhpTui\Tui\Extension\Core\Widget\BufferWidget; use PhpTui\Tui\Extension\Core\Widget\CompositeWidget; use PhpTui\Tui\Extension\Core\Widget\GridWidget; -use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarOrientation; -use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarSymbols; -use PhpTui\Tui\Extension\Core\Widget\ScrollbarWidget; use PhpTui\Tui\Layout\Constraint; +use PhpTui\Tui\Position\Position; +use PhpTui\Tui\Style\Style; use PhpTui\Tui\Widget\Direction; use PhpTui\Tui\Widget\Widget; use Tapper\Console\CommandAttributes\FirstRender; @@ -219,6 +221,29 @@ protected function view(Area $area): Widget { $this->fill(); + $thumbBounds = Scroll::proportionalThumb($this->count, $this->visible, $this->appState->offset, $area->height); + + // Built by hand rather than via php-tui's ScrollbarWidget/ScrollbarRenderer: that + // combo can only size the thumb off ScrollbarState's single contentLength field, + // which is wrong for a short list barely taller than the viewport (see the docblock + // on Scroll::proportionalThumb()). Details.php already draws its scrollbar the same + // way for the same reason. + $scrollbar = BufferWidget::new(function (BufferContext $context) use ($thumbBounds): void { + $trackArea = $context->area; + $x = max(0, $trackArea->right() - 1); + $neutralStyle = Style::default()->fg(AnsiColor::Reset)->bg(AnsiColor::Reset); + + for ($y = $trackArea->top(); $y < $trackArea->bottom(); $y++) { + $isThumb = $thumbBounds !== null + && ($y - $trackArea->top()) >= $thumbBounds[0] + && ($y - $trackArea->top()) < $thumbBounds[1]; + + $context->buffer->get(Position::at($x, $y)) + ->setChar($isThumb ? '█' : ($thumbBounds === null ? ' ' : '│')) + ->setStyle($neutralStyle); + } + }); + return CompositeWidget::fromWidgets( GridWidget::default() ->direction(Direction::Vertical) @@ -229,12 +254,7 @@ protected function view(Area $area): Widget $this->listItems ), ), - ScrollbarWidget::default() - ->state(Scroll::scrollbarState($this->count, $this->visible, $this->appState->offset)) - ->orientation(ScrollbarOrientation::VerticalRight) - ->symbols(new ScrollbarSymbols('│', '█', '', '')) - ->endSymbol(null) - ->beginSymbol(null), + $scrollbar, ); } } diff --git a/src/Console/Components/Splash.php b/src/Console/Components/Splash.php index cc79095..105183e 100644 --- a/src/Console/Components/Splash.php +++ b/src/Console/Components/Splash.php @@ -14,7 +14,7 @@ class Splash extends Component { - private const string TAPPER = 'T A P P E R'; + private const TAPPER = 'T A P P E R'; protected function view(Area $area): Widget { diff --git a/src/Console/ErrorHandler.php b/src/Console/ErrorHandler.php index 102d62d..ad65c34 100644 --- a/src/Console/ErrorHandler.php +++ b/src/Console/ErrorHandler.php @@ -19,7 +19,7 @@ */ final class ErrorHandler { - private const int NOTICE_SECONDS = 5; + private const NOTICE_SECONDS = 5; public static function install(AppState $appState): void { diff --git a/src/Console/MessageFormatter.php b/src/Console/MessageFormatter.php index 359972c..e9fa383 100644 --- a/src/Console/MessageFormatter.php +++ b/src/Console/MessageFormatter.php @@ -11,21 +11,21 @@ class MessageFormatter { - private const string STRING_COLOR = '9ece6a'; + private const STRING_COLOR = '9ece6a'; - private const string KEY_COLOR = '73daca'; + private const KEY_COLOR = '73daca'; - private const string NUMBER_COLOR = 'fd9d63'; + private const NUMBER_COLOR = 'fd9d63'; - private const string BOOL_COLOR = 'fd9d63'; + private const BOOL_COLOR = 'fd9d63'; - private const string NULL_COLOR = '2bc3de'; + private const NULL_COLOR = '2bc3de'; - private const string BRACKETS_COLOR = Palette::TEXT_DEFAULT; + private const BRACKETS_COLOR = Palette::TEXT_DEFAULT; - private const string PUNCTUATION_COLOR = '89ddff'; + private const PUNCTUATION_COLOR = '89ddff'; - private const string ERROR_COLOR = Palette::ERROR; + private const ERROR_COLOR = Palette::ERROR; public static function colorizeInlineJson(string $json): array { diff --git a/src/Console/Palette.php b/src/Console/Palette.php index b6782cd..e840a79 100644 --- a/src/Console/Palette.php +++ b/src/Console/Palette.php @@ -10,11 +10,11 @@ */ final class Palette { - public const string ACCENT = '7aa2f7'; + public const ACCENT = '7aa2f7'; - public const string TEXT_DEFAULT = 'c0caf5'; + public const TEXT_DEFAULT = 'c0caf5'; - public const string SELECTION_BG = '2a2e42'; + public const SELECTION_BG = '2a2e42'; - public const string ERROR = 'f7768e'; + public const ERROR = 'f7768e'; } diff --git a/src/Console/PhpHighlighter.php b/src/Console/PhpHighlighter.php index 4d308b5..e2deec2 100644 --- a/src/Console/PhpHighlighter.php +++ b/src/Console/PhpHighlighter.php @@ -19,19 +19,19 @@ */ final class PhpHighlighter { - private const string KEYWORD_COLOR = 'bb9af7'; + private const KEYWORD_COLOR = 'bb9af7'; - private const string VARIABLE_COLOR = '7dcfff'; + private const VARIABLE_COLOR = '7dcfff'; - private const string STRING_COLOR = '9ece6a'; + private const STRING_COLOR = '9ece6a'; - private const string NUMBER_COLOR = 'ff9e64'; + private const NUMBER_COLOR = 'ff9e64'; - private const string COMMENT_COLOR = '565f89'; + private const COMMENT_COLOR = '565f89'; - private const string FUNCTION_COLOR = '7aa2f7'; + private const FUNCTION_COLOR = '7aa2f7'; - private const array KEYWORD_TOKENS = [ + private const KEYWORD_TOKENS = [ T_IF, T_ELSE, T_ELSEIF, T_ENDIF, T_FOR, T_FOREACH, T_ENDFOR, T_ENDFOREACH, T_WHILE, T_ENDWHILE, T_DO, T_SWITCH, T_ENDSWITCH, T_CASE, T_DEFAULT, T_BREAK, T_CONTINUE, T_FUNCTION, T_FN, T_RETURN, T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, @@ -44,14 +44,14 @@ final class PhpHighlighter T_INSTEADOF, ]; - private const array STRING_TOKENS = [ + private const STRING_TOKENS = [ T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, T_START_HEREDOC, T_END_HEREDOC, ]; - private const array COMMENT_TOKENS = [T_COMMENT, T_DOC_COMMENT]; + private const COMMENT_TOKENS = [T_COMMENT, T_DOC_COMMENT]; - private const array BARE_KEYWORDS = ['true', 'false', 'null', 'self', 'parent']; + private const BARE_KEYWORDS = ['true', 'false', 'null', 'self', 'parent']; /** * @return Span[] diff --git a/src/Console/State/AppState.php b/src/Console/State/AppState.php index 0ecda6f..d9db29e 100644 --- a/src/Console/State/AppState.php +++ b/src/Console/State/AppState.php @@ -8,7 +8,7 @@ /** * @property string $version - * @property int $port + * @property ?int $port * @property bool $live * @property bool $showDot * @property bool $typingMode @@ -41,7 +41,7 @@ class AppState */ public function __construct( private string $version = '', - private int $port = 2137, + private ?int $port = null, private bool $live = true, private bool $showDot = true, private bool $typingMode = false, diff --git a/src/Console/Support/Scroll.php b/src/Console/Support/Scroll.php index 84164be..d3f7bc9 100644 --- a/src/Console/Support/Scroll.php +++ b/src/Console/Support/Scroll.php @@ -4,18 +4,12 @@ namespace Tapper\Console\Support; -use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarState; use Tapper\Console\State\AppState; class Scroll { public function __construct(private readonly AppState $appState) {} - public static function scrollbarState(int $count, int $visible, int $offset): ScrollbarState - { - return new ScrollbarState(max(0, $count - $visible), $offset, $visible); - } - /** * php-tui's ScrollbarState only has one `contentLength` field, which * ScrollbarRenderer uses as the denominator for *both* the thumb's position diff --git a/src/Console/Windows/Main.php b/src/Console/Windows/Main.php index fc84120..b90f8a1 100644 --- a/src/Console/Windows/Main.php +++ b/src/Console/Windows/Main.php @@ -25,9 +25,9 @@ class Main extends Component { - private const int MIN_WIDTH = 40; + private const MIN_WIDTH = 40; - private const int MIN_HEIGHT = 8; + private const MIN_HEIGHT = 8; protected array $components = [ Header::class, diff --git a/src/LogPath.php b/src/LogPath.php index af61417..3310796 100644 --- a/src/LogPath.php +++ b/src/LogPath.php @@ -4,18 +4,10 @@ namespace Tapper; -use RuntimeException; - class LogPath { public static function resolve(): string { - $projectPath = realpath(__DIR__.'/..'); - - if ($projectPath === false) { - throw new RuntimeException('[Tapper] could not resolve package root to locate tapper.log.'); - } - - return $projectPath.'/tapper.log'; + return sys_get_temp_dir().'/tapper.log'; } } diff --git a/src/Rpc/JsonRpcClient.php b/src/Rpc/JsonRpcClient.php index a949697..2d38f36 100644 --- a/src/Rpc/JsonRpcClient.php +++ b/src/Rpc/JsonRpcClient.php @@ -10,7 +10,7 @@ class JsonRpcClient { public function __construct( protected string $host = '127.0.0.1', - protected int $port = 2137, + protected ?int $port = null, protected float $timeout = 0.5, protected float $pauseTimeout = 3600, ) {} @@ -19,7 +19,11 @@ public function call(JsonRpc $jsonRpc): ?array { $payload = $jsonRpc->payload(); - $socket = @stream_socket_client('unix://'.SocketPath::resolve(), $errno, $errstr, $this->timeout); + $target = $this->port !== null + ? "tcp://{$this->host}:{$this->port}" + : 'unix://'.SocketPath::resolve(); + + $socket = @stream_socket_client($target, $errno, $errstr, $this->timeout); if (! $socket) { return null; diff --git a/src/Runtime/Tapper.php b/src/Runtime/Tapper.php index 2b14332..ee43042 100644 --- a/src/Runtime/Tapper.php +++ b/src/Runtime/Tapper.php @@ -33,7 +33,8 @@ class Tapper public function __construct() { if (self::$client === null) { - self::$client = new JsonRpcClient; + $port = getenv('TAPPER_PORT'); + self::$client = new JsonRpcClient(port: $port !== false && $port !== '' ? (int) $port : null); } $this->collectDebugInfo(); diff --git a/src/Server.php b/src/Server.php index ca0a371..4146875 100644 --- a/src/Server.php +++ b/src/Server.php @@ -27,106 +27,126 @@ public function __construct( private readonly EventBus $eventBus ) {} - public function run(): void + public function run(?int $port = null): void { + $server = $this->createSocketServer($port); + + $server->on('connection', function (ConnectionInterface $conn) { + $this->handleConnection($conn); + }); + } + + private function createSocketServer(?int $port): SocketServer + { + if ($port !== null) { + return new SocketServer("127.0.0.1:{$port}"); + } + $socketPath = SocketPath::resolve(); @unlink($socketPath); - $server = new SocketServer('unix://'.$socketPath); - $server->on('connection', function (ConnectionInterface $conn) { - $decoder = new Decoder($conn, true); - $encoder = new Encoder($conn, true); - - $decoder->on('data', function ($message) use ($encoder) { - - if (($message['jsonrpc'] ?? '') !== '2.0') { - $encoder->write([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32600, - 'message' => 'Invalid Request', - ], - 'id' => $message['id'] ?? null, - ]); - - return; - } - - $method = $message['method'] ?? ''; - $params = $message['params'] ?? []; - $id = $message['id'] ?? null; - - switch ($method) { - case 'log': - $kind = $params['kind'] ?? 'log'; - $isAppended = $this->appState->appendLog(new LogItem( - $this->id, - $params['microtime'], - $kind === 'error' ? $params['message'] : json_encode($params['message'], JSON_UNESCAPED_UNICODE), - $params['caller'], - $params['trace'], - $params['rootDir'], - $params['code'], - kind: $kind, - )); - - $encoder->write([ - 'jsonrpc' => '2.0', - 'result' => 'ok', - 'id' => $id, - ]); - - if ($isAppended) { - $this->id++; - } - break; - - case 'wait': - $isAppended = $this->appState->appendLog(new LogItem( - $this->id, - $params['microtime'], - "⏸ {$params['message']} — press ENTER to continue", - $params['caller'], - $params['trace'], - $params['rootDir'], - $params['code'], - kind: 'wait', - )); - - if ($isAppended) { - $this->id++; - } - - $this->appState->pendingWaits++; - - $this->waitResolvers[] = function () use ($encoder, $id) { - $encoder->write([ - 'jsonrpc' => '2.0', - 'result' => 'continue', - 'id' => $id, - ]); - - $this->appState->pendingWaits = max(0, $this->appState->pendingWaits - 1); - }; - - $this->registerWaitListener(); - - break; - - default: - $encoder->write([ - 'jsonrpc' => '2.0', - 'error' => [ - 'code' => -32601, - 'message' => "Method '{$method}' not found", - ], - 'id' => $id, - ]); - } - }); + return new SocketServer('unix://'.$socketPath); + } + + private function handleConnection(ConnectionInterface $conn): void + { + $decoder = new Decoder($conn, true); + $encoder = new Encoder($conn, true); + + $decoder->on('data', function ($message) use ($encoder) { + $this->handleMessage($message, $encoder); }); } + private function handleMessage(array $message, Encoder $encoder): void + { + if (($message['jsonrpc'] ?? '') !== '2.0') { + $this->writeError($encoder, -32600, 'Invalid Request', $message['id'] ?? null); + + return; + } + + $method = $message['method'] ?? ''; + $params = $message['params'] ?? []; + $id = $message['id'] ?? null; + + match ($method) { + 'log' => $this->handleLog($params, $id, $encoder), + 'wait' => $this->handleWait($params, $id, $encoder), + default => $this->writeError($encoder, -32601, "Method '{$method}' not found", $id), + }; + } + + private function handleLog(array $params, mixed $id, Encoder $encoder): void + { + $kind = $params['kind'] ?? 'log'; + + $isAppended = $this->appState->appendLog(new LogItem( + $this->id, + $params['microtime'], + $kind === 'error' ? $params['message'] : json_encode($params['message'], JSON_UNESCAPED_UNICODE), + $params['caller'], + $params['trace'], + $params['rootDir'], + $params['code'], + kind: $kind, + )); + + $this->writeResult($encoder, 'ok', $id); + + if ($isAppended) { + $this->id++; + } + } + + private function handleWait(array $params, mixed $id, Encoder $encoder): void + { + $isAppended = $this->appState->appendLog(new LogItem( + $this->id, + $params['microtime'], + "⏸ {$params['message']} — press ENTER to continue", + $params['caller'], + $params['trace'], + $params['rootDir'], + $params['code'], + kind: 'wait', + )); + + if ($isAppended) { + $this->id++; + } + + $this->appState->pendingWaits++; + + $this->waitResolvers[] = function () use ($encoder, $id) { + $this->writeResult($encoder, 'continue', $id); + $this->appState->pendingWaits = max(0, $this->appState->pendingWaits - 1); + }; + + $this->registerWaitListener(); + } + + private function writeResult(Encoder $encoder, mixed $result, mixed $id): void + { + $encoder->write([ + 'jsonrpc' => '2.0', + 'result' => $result, + 'id' => $id, + ]); + } + + private function writeError(Encoder $encoder, int $code, string $message, mixed $id): void + { + $encoder->write([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + 'id' => $id, + ]); + } + /** * Registers a single, permanent Enter listener the first time it's needed, instead of * one per wait() call. Each keypress resolves the oldest pending wait in FIFO order, so diff --git a/src/SocketPath.php b/src/SocketPath.php index 234a801..a70d860 100644 --- a/src/SocketPath.php +++ b/src/SocketPath.php @@ -4,18 +4,10 @@ namespace Tapper; -use RuntimeException; - class SocketPath { public static function resolve(): string { - $projectPath = realpath(__DIR__.'/..'); - - if ($projectPath === false) { - throw new RuntimeException('[Tapper] could not resolve package root to locate tapper.sock.'); - } - - return $projectPath.'/tapper.sock'; + return sys_get_temp_dir().'/tapper.sock'; } } diff --git a/tests/Unit/LogPathTest.php b/tests/Unit/LogPathTest.php new file mode 100644 index 0000000..47bf9a5 --- /dev/null +++ b/tests/Unit/LogPathTest.php @@ -0,0 +1,9 @@ +toBe(sys_get_temp_dir().'/tapper.log'); +}); diff --git a/tests/Unit/Rpc/JsonRpcClientTest.php b/tests/Unit/Rpc/JsonRpcClientTest.php new file mode 100644 index 0000000..30b5c4f --- /dev/null +++ b/tests/Unit/Rpc/JsonRpcClientTest.php @@ -0,0 +1,95 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + + $address = trim(fgets($pipes[1])); + $port = (int) substr($address, strrpos($address, ':') + 1); + + $stop = function () use ($process, $pipes, $scriptPath) { + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + if (is_resource($process)) { + proc_close($process); + } + @unlink($scriptPath); + }; + + return [$port, $stop]; +} + +describe('TCP transport (port configured)', function () { + it('connects over TCP and returns the decoded response', function () { + [$port, $stop] = startFakeTcpServer(json_encode(['jsonrpc' => '2.0', 'result' => 'ok', 'id' => 'x'])."\n"); + + $client = new JsonRpcClient(port: $port, timeout: 2.0); + $response = $client->call(new JsonRpcRequest('log', [], id: 'x')); + + $stop(); + + expect($response)->toBe(['jsonrpc' => '2.0', 'result' => 'ok', 'id' => 'x']); + }); + + it('returns null when the response has no result key', function () { + [$port, $stop] = startFakeTcpServer(json_encode(['jsonrpc' => '2.0', 'error' => ['code' => -32601, 'message' => 'nope']])."\n"); + + $client = new JsonRpcClient(port: $port, timeout: 2.0); + $response = $client->call(new JsonRpcRequest('log', [])); + + $stop(); + + expect($response)->toBeNull(); + }); + + it('returns null when nothing is listening on the configured port', function () { + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $address = stream_socket_get_name($server, false); + $port = (int) substr($address, strrpos($address, ':') + 1); + fclose($server); + + $client = new JsonRpcClient(port: $port, timeout: 0.5); + $response = $client->call(new JsonRpcRequest('log', [])); + + expect($response)->toBeNull(); + }); +}); diff --git a/tests/Unit/ScrollTest.php b/tests/Unit/ScrollTest.php index c86a976..42e21b9 100644 --- a/tests/Unit/ScrollTest.php +++ b/tests/Unit/ScrollTest.php @@ -187,32 +187,6 @@ }); }); -describe('scrollbar state', function () { - it('maps count/visible/offset onto content length, viewport length and position', function () { - $state = Scroll::scrollbarState(count: 100, visible: 20, offset: 35); - - expect($state->contentLength)->toBe(80) - ->and($state->viewportContentLength)->toBe(20) - ->and($state->position)->toBe(35); - }); - - it('never lets content length go negative when everything fits on screen', function () { - $state = Scroll::scrollbarState(count: 5, visible: 20, offset: 0); - - expect($state->contentLength)->toBe(0); - }); - - it('uses the max offset (count - visible) as content length so the thumb can reach the bottom', function () { - $count = 100; - $visible = 20; - $maxOffset = $count - $visible; - - $state = Scroll::scrollbarState($count, $visible, $maxOffset); - - expect($state->position)->toBe($state->contentLength); - }); -}); - describe('proportional thumb', function () { it('returns null when everything already fits on screen', function () { expect(Scroll::proportionalThumb(count: 5, visible: 20, offset: 0, trackHeight: 20))->toBeNull(); diff --git a/tests/Unit/ScrollbarRenderTest.php b/tests/Unit/ScrollbarRenderTest.php deleted file mode 100644 index acff7d5..0000000 --- a/tests/Unit/ScrollbarRenderTest.php +++ /dev/null @@ -1,68 +0,0 @@ -state(Scroll::scrollbarState($count, $visible, $offset)) - ->orientation(ScrollbarOrientation::VerticalRight) - ->symbols(new ScrollbarSymbols('│', THUMB_SYMBOL, '', '')) - ->endSymbol(null) - ->beginSymbol(null); - - (new ScrollbarRenderer)->render(new AggregateWidgetRenderer([]), $widget, $buffer, $area); - - $column = []; - for ($y = 0; $y < $trackHeight; $y++) { - $column[] = $buffer->get(Position::at($area->right() - 1, $y))->char; - } - - return $column; -} - -it('places the thumb at the very top when scrolled to the top', function () { - $column = renderScrollbarColumn(count: 100, visible: 20, offset: 0, trackHeight: 40); - - expect($column[0])->toBe(THUMB_SYMBOL); -}); - -it('places the thumb at the very bottom when scrolled to the bottom', function () { - $column = renderScrollbarColumn(count: 100, visible: 20, offset: 80, trackHeight: 40); - - expect($column[count($column) - 1])->toBe(THUMB_SYMBOL); -}); - -it('sizes the thumb proportionally instead of collapsing to a single row', function () { - $column = renderScrollbarColumn(count: 100, visible: 20, offset: 0, trackHeight: 40); - - $thumbRows = count(array_filter($column, fn (string $char) => $char === THUMB_SYMBOL)); - - expect($thumbRows)->toBeGreaterThan(1); -}); - -it('draws nothing when everything already fits on screen', function () { - $column = renderScrollbarColumn(count: 5, visible: 20, offset: 0, trackHeight: 40); - - $thumbRows = count(array_filter($column, fn (string $char) => $char === THUMB_SYMBOL)); - - expect($thumbRows)->toBe(0); -}); diff --git a/tests/Unit/SocketPathTest.php b/tests/Unit/SocketPathTest.php new file mode 100644 index 0000000..3e7698e --- /dev/null +++ b/tests/Unit/SocketPathTest.php @@ -0,0 +1,9 @@ +toBe(sys_get_temp_dir().'/tapper.sock'); +});