diff --git a/composer.json b/composer.json index fcf710f..5347c81 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "veewee/xml": "^3.0", "azjezz/psl": "^3.0", "symfony/console": "^5.4 || ^6.0 || ^7.0", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11", + "php-tui/php-tui": "^0.2.1" }, "require-dev": { "symfony/var-dumper": "^6.1 || ^7.0", diff --git a/src/Console/Command/InspectUICommand.php b/src/Console/Command/InspectUICommand.php new file mode 100644 index 0000000..e2ba71e --- /dev/null +++ b/src/Console/Command/InspectUICommand.php @@ -0,0 +1,104 @@ +setDescription('Inspects WSDL file through a user interface.'); + $this->addArgument('wsdl', InputArgument::REQUIRED, 'Provide the URI of the WSDL you want to validate'); + $this->addOption('loader', 'l', InputOption::VALUE_REQUIRED, 'Customize the WSDL loader file that will be used'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $terminal = Terminal::new(); + $terminal->execute(Actions::alternateScreenEnable()); + $terminal->execute(Actions::enableMouseCapture()); + $terminal->execute(Actions::cursorHide()); + $terminal->enableRawMode(); + + $display = DisplayBuilder::default(PhpTermBackend::new($terminal))->build(); + $state = $this->loadState($input, $display); + + try { + while ($state->running) { + while (null !== $event = $terminal->events()->next()) { + $state->handle($event); + } + + $display->draw(Layout::create($state)); + + usleep(50_000); + } + } finally { + $terminal->disableRawMode(); + $terminal->execute(Actions::alternateScreenDisable()); + $terminal->execute(Actions::disableMouseCapture()); + $terminal->execute(Actions::cursorShow()); + $terminal->execute(Actions::clear(ClearType::All)); + } + + return self::SUCCESS; + } + + private function loadState(InputInterface $input, Display $display): UIState + { + $wsdl = non_empty_string()->assert($input->getArgument('wsdl')); + /** @var Ref> $info */ + $info = new Ref(['Loading WSDL ...']); + $display->draw(LoadingWidget::create($info->value)); + + $loader = ConfiguredLoader::createFromConfig( + $input->getOption('loader'), + static fn (WsdlLoader $loader) => new FlatteningLoader( + new CallbackLoader(static function (string $location) use ($loader, $display, $info): string { + $info->value[] = '> Loading '.$location.' ...'; + $currentIndex = count($info->value) - 1; + $display->draw(LoadingWidget::create($info->value)); + + $result = $loader($location); + + $info->value[$currentIndex] .= ' OK'; + $display->draw(LoadingWidget::create($info->value)); + + return $result; + }) + ), + ); + + return UIState::load($wsdl, $loader); + } +} diff --git a/src/Console/UI/Component.php b/src/Console/UI/Component.php new file mode 100644 index 0000000..916f2b6 --- /dev/null +++ b/src/Console/UI/Component.php @@ -0,0 +1,10 @@ +borders(Borders::ALL)->style(Style::default()->white()) + ->widget( + TabsWidget::fromTitles( + ...$lines + ) + ); + } +} diff --git a/src/Console/UI/Components/LoadingWidget.php b/src/Console/UI/Components/LoadingWidget.php new file mode 100644 index 0000000..da93755 --- /dev/null +++ b/src/Console/UI/Components/LoadingWidget.php @@ -0,0 +1,29 @@ + $messages + */ + public static function create(array $messages): Widget + { + return ListWidget::default() + ->items( + ...map( + $messages, + static fn (string $message) => ListItem::new(Text::fromString($message)) + ) + ) + ->select(count($messages) - 1) + ->highlightStyle(Style::default()->white()->onBlue()); + } +} diff --git a/src/Console/UI/Components/MetaTable.php b/src/Console/UI/Components/MetaTable.php new file mode 100644 index 0000000..b5ef91a --- /dev/null +++ b/src/Console/UI/Components/MetaTable.php @@ -0,0 +1,84 @@ +bold(); + + return TableWidget::default() + ->widths( + Constraint::min(50), + Constraint::percentage(100), + ) + + ->header( + TableRow::fromCells( + new TableCell(Text::fromLine(Line::fromString('Key')), $headerStyle), + new TableCell(Text::fromLine(Line::fromString('Value')), $headerStyle), + ) + ) + ->rows(...map( + self::buildKeyPairs($meta), + static fn ($current) => TableRow::fromCells( + TableCell::fromString($current[0]), + TableCell::fromString($current[1]), + ) + )); + } + + /** + * @return array + */ + private static function buildKeyPairs(object $object): array + { + $rc = new ReflectionClass($object); + + return filter_nulls( + map( + $rc->getProperties(), + /** @return array{0: non-empty-string, 1: string}|null */ + static function (ReflectionProperty $prop) use ($object): ?array { + $value = self::tryStringifyValue($prop->getValue($object)); + if ($value === null) { + return null; + } + + return [$prop->getName(), $value]; + } + ) + ); + } + + private static function tryStringifyValue(mixed $value): ?string + { + try { + return match (true) { + is_array($value) => json_encode($value, JSON_UNESCAPED_SLASHES), + is_bool($value) => $value ? 'true' : 'false', + is_scalar($value) => (string)$value, + $value instanceof Option => $value->map(self::tryStringifyValue(...))->unwrapOr(null), + default => null, + }; + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Console/UI/Components/Navigation.php b/src/Console/UI/Components/Navigation.php new file mode 100644 index 0000000..c1c0e00 --- /dev/null +++ b/src/Console/UI/Components/Navigation.php @@ -0,0 +1,34 @@ +borders(Borders::ALL)->style(Style::default()->white()) + ->widget( + TabsWidget::fromTitles( + Line::parse('[q]uit'), + ...map( + $state->availablePages, + static fn (Page $page) => Line::parse($page->title()) + ) + ) + ->select((int)array_search(get_class($state->currentPage), keys($state->availablePages), true) + 1) + ->highlightStyle(Style::default()->white()->onBlue()) + ); + } +} diff --git a/src/Console/UI/Components/ScrollableTextArea.php b/src/Console/UI/Components/ScrollableTextArea.php new file mode 100644 index 0000000..ed486d9 --- /dev/null +++ b/src/Console/UI/Components/ScrollableTextArea.php @@ -0,0 +1,17 @@ +value); + $paragraph->scroll = [$state->position, 0]; + + return $paragraph; + } +} diff --git a/src/Console/UI/Components/ScrollableTextAreaState.php b/src/Console/UI/Components/ScrollableTextAreaState.php new file mode 100644 index 0000000..2a365b8 --- /dev/null +++ b/src/Console/UI/Components/ScrollableTextAreaState.php @@ -0,0 +1,48 @@ +position = (int) max([0, $this->position - 1]); + } + + public function scrollDown(): void + { + $this->position = (int) min([$this->position + 1, count(explode("\n", $this->value))]); + } + + public function handle(Event $event): void + { + if ($event instanceof Event\MouseEvent) { + match ($event->kind) { + MouseEventKind::ScrollUp => $this->scrollUp(), + MouseEventKind::ScrollDown => $this->scrollDown(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Search.php b/src/Console/UI/Components/Search.php new file mode 100644 index 0000000..c0359e7 --- /dev/null +++ b/src/Console/UI/Components/Search.php @@ -0,0 +1,23 @@ +titles(Title::fromString('Search')) + ->borders(Borders::ALL) + ->widget( + ParagraphWidget::fromLines(Line::fromString($state->query)), + ); + } +} diff --git a/src/Console/UI/Components/SearchState.php b/src/Console/UI/Components/SearchState.php new file mode 100644 index 0000000..7b247cc --- /dev/null +++ b/src/Console/UI/Components/SearchState.php @@ -0,0 +1,47 @@ +locked = true; + } + + public function unlock(): void + { + $this->locked = false; + } + + public function handle(Event $event): void + { + if ($this->locked) { + return; + } + + if ($event instanceof Event\CharKeyEvent && $event->modifiers === KeyModifiers::NONE) { + $this->query .= $event->char; + } + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Backspace => $this->query = substr($this->query, 0, -1), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/BindingsState.php b/src/Console/UI/Components/Wsdl/BindingsState.php new file mode 100644 index 0000000..9224897 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/BindingsState.php @@ -0,0 +1,90 @@ +trackTextAreaState(); + } + + public function trackTextAreaState(): ScrollableTextAreaState + { + return $this->textAreaState= ScrollableTextAreaState::json($this->currentBinding(), 'No binding found'); + } + + /** + * @return list + */ + public function bindings(): array + { + $query = (string) $this->pageState->search?->query; + + return filter( + $this->pageState->wsdl()->bindings->items, + static fn (Binding $binding) => $query === '' || mb_stripos($binding->name, $query) !== false + ); + } + + public function currentBindingIndex(): int + { + return $this->currentBinding; + } + + public function currentBinding(): ?Binding + { + return $this->bindings()[$this->currentBinding] ?? null; + } + + public function totalBindings(): int + { + return count($this->pageState->wsdl()->bindings->items); + } + + public function totalFilteredBindings(): int + { + return count($this->bindings()); + } + + + public function up(): void + { + $this->currentBinding = (int) max([0, $this->currentBinding - 1]); + $this->trackTextAreaState(); + } + + public function down(): void + { + $this->currentBinding = (int) min([count($this->bindings()) - 1, $this->currentBinding + 1]); + $this->trackTextAreaState(); + } + + public function handle(Event $event): void + { + $this->textAreaState->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/BindingsWidget.php b/src/Console/UI/Components/Wsdl/BindingsWidget.php new file mode 100644 index 0000000..35ae45a --- /dev/null +++ b/src/Console/UI/Components/Wsdl/BindingsWidget.php @@ -0,0 +1,55 @@ +direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(20), + Constraint::percentage(80), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Bindings (%s/%s)', + $state->totalFilteredBindings(), + $state->totalBindings(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $state->bindings(), + static fn (Binding $info) => ListItem::new(Text::fromString($info->name)) + ) + ) + ->select($state->currentBindingIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + BlockWidget::default() + ->titles(Title::fromString('Details (scrollable)')) + ->borders(Borders::ALL) + ->widget(ScrollableTextArea::create($state->textAreaState)), + ); + } +} diff --git a/src/Console/UI/Components/Wsdl/MessagesState.php b/src/Console/UI/Components/Wsdl/MessagesState.php new file mode 100644 index 0000000..c950834 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/MessagesState.php @@ -0,0 +1,90 @@ +trackTextAreaState(); + } + + public function trackTextAreaState(): ScrollableTextAreaState + { + return $this->textAreaState= ScrollableTextAreaState::json($this->currentMessage(), 'No message found'); + } + + /** + * @return list + */ + public function messages(): array + { + $query = (string) $this->pageState->search?->query; + + return filter( + $this->pageState->wsdl()->messages->items, + static fn (Message $message) => $query === '' || mb_stripos($message->name, $query) !== false + ); + } + + public function currentMessageIndex(): int + { + return $this->currentMessage; + } + + public function currentMessage(): ?Message + { + return $this->messages()[$this->currentMessage] ?? null; + } + + public function totalMessages(): int + { + return count($this->pageState->wsdl()->messages->items); + } + + public function totalFilteredMessages(): int + { + return count($this->messages()); + } + + + public function up(): void + { + $this->currentMessage = (int) max([0, $this->currentMessage - 1]); + $this->trackTextAreaState(); + } + + public function down(): void + { + $this->currentMessage = (int) min([count($this->messages()) - 1, $this->currentMessage + 1]); + $this->trackTextAreaState(); + } + + public function handle(Event $event): void + { + $this->textAreaState->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/MessagesWidget.php b/src/Console/UI/Components/Wsdl/MessagesWidget.php new file mode 100644 index 0000000..2bccac7 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/MessagesWidget.php @@ -0,0 +1,55 @@ +direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(20), + Constraint::percentage(80), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Messages (%s/%s)', + $state->totalFilteredMessages(), + $state->totalMessages(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $state->messages(), + static fn (Message $info) => ListItem::new(Text::fromString($info->name)) + ) + ) + ->select($state->currentMessageIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + BlockWidget::default() + ->titles(Title::fromString('Details (scrollable)')) + ->borders(Borders::ALL) + ->widget(ScrollableTextArea::create($state->textAreaState)), + ); + } +} diff --git a/src/Console/UI/Components/Wsdl/NamespacesState.php b/src/Console/UI/Components/Wsdl/NamespacesState.php new file mode 100644 index 0000000..a7ef7a2 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/NamespacesState.php @@ -0,0 +1,73 @@ + + */ + public function namespaces(): array + { + $query = (string) $this->pageState->search?->query; + + return filter_with_key( + $this->pageState->wsdl()->namespaces->namespaceToNameMap, + static fn (string $namespace, string $prefix) => $query === '' + || mb_stripos($namespace, $query) !== false + || mb_stripos($prefix, $query) !== false + ); + } + + public function currentNamespaceIndex(): int + { + return $this->currentNamespace; + } + + public function totalNamespaces(): int + { + return count($this->pageState->wsdl()->namespaces->namespaceToNameMap); + } + + public function totalFilteredNamespaces(): int + { + return count($this->namespaces()); + } + + + public function up(): void + { + $this->currentNamespace = (int) max([0, $this->currentNamespace - 1]); + } + + public function down(): void + { + $this->currentNamespace = (int) min([count($this->namespaces()) - 1, $this->currentNamespace + 1]); + } + + public function handle(Event $event): void + { + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/NamespacesWidget.php b/src/Console/UI/Components/Wsdl/NamespacesWidget.php new file mode 100644 index 0000000..c51d758 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/NamespacesWidget.php @@ -0,0 +1,55 @@ +bold(); + + return BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Namespaces (%s/%s)', + $state->totalFilteredNamespaces(), + $state->totalNamespaces(), + ))) + ->borders(Borders::ALL) + ->widget( + TableWidget::default() + ->widths( + Constraint::percentage(25), + Constraint::percentage(75), + ) + + ->header( + TableRow::fromCells( + new TableCell(Text::fromLine(Line::fromString('Prefix')), $headerStyle), + new TableCell(Text::fromLine(Line::fromString('Namespace')), $headerStyle), + ) + ) + ->rows(...map_with_key( + $state->namespaces(), + static fn (string $namespace, string $prefix) => TableRow::fromCells( + TableCell::fromString($prefix), + TableCell::fromString($namespace), + ) + )) + ->select($state->currentNamespaceIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ); + } +} diff --git a/src/Console/UI/Components/Wsdl/PortTypesState.php b/src/Console/UI/Components/Wsdl/PortTypesState.php new file mode 100644 index 0000000..df78e31 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/PortTypesState.php @@ -0,0 +1,90 @@ +trackTextAreaState(); + } + + public function trackTextAreaState(): ScrollableTextAreaState + { + return $this->textAreaState= ScrollableTextAreaState::json($this->currentPortType(), 'No port type found'); + } + + /** + * @return list + */ + public function portTypes(): array + { + $query = (string) $this->pageState->search?->query; + + return filter( + $this->pageState->wsdl()->portTypes->items, + static fn (PortType $portType) => $query === '' || mb_stripos($portType->name, $query) !== false + ); + } + + public function currentPortTypeIndex(): int + { + return $this->currentPortType; + } + + public function currentPortType(): ?PortType + { + return $this->portTypes()[$this->currentPortType] ?? null; + } + + public function totalPortTypes(): int + { + return count($this->pageState->wsdl()->portTypes->items); + } + + public function totalFilteredPortTypes(): int + { + return count($this->portTypes()); + } + + + public function up(): void + { + $this->currentPortType = (int) max([0, $this->currentPortType - 1]); + $this->trackTextAreaState(); + } + + public function down(): void + { + $this->currentPortType = (int) min([count($this->portTypes()) - 1, $this->currentPortType + 1]); + $this->trackTextAreaState(); + } + + public function handle(Event $event): void + { + $this->textAreaState->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/PortTypesWidget.php b/src/Console/UI/Components/Wsdl/PortTypesWidget.php new file mode 100644 index 0000000..64c57cd --- /dev/null +++ b/src/Console/UI/Components/Wsdl/PortTypesWidget.php @@ -0,0 +1,55 @@ +direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(20), + Constraint::percentage(80), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Port types (%s/%s)', + $state->totalFilteredPortTypes(), + $state->totalPortTypes(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $state->portTypes(), + static fn (PortType $info) => ListItem::new(Text::fromString($info->name)) + ) + ) + ->select($state->currentPortTypeIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + BlockWidget::default() + ->titles(Title::fromString('Details (scrollable)')) + ->borders(Borders::ALL) + ->widget(ScrollableTextArea::create($state->textAreaState)), + ); + } +} diff --git a/src/Console/UI/Components/Wsdl/ServicesState.php b/src/Console/UI/Components/Wsdl/ServicesState.php new file mode 100644 index 0000000..3ab15cc --- /dev/null +++ b/src/Console/UI/Components/Wsdl/ServicesState.php @@ -0,0 +1,90 @@ +trackTextAreaState(); + } + + public function trackTextAreaState(): ScrollableTextAreaState + { + return $this->textAreaState= ScrollableTextAreaState::json($this->currentService(), 'No service found'); + } + + /** + * @return list + */ + public function services(): array + { + $query = (string) $this->pageState->search?->query; + + return filter( + $this->pageState->wsdl()->services->items, + static fn (Service $service) => $query === '' || mb_stripos($service->name, $query) !== false + ); + } + + public function currentServiceIndex(): int + { + return $this->currentService; + } + + public function currentService(): ?Service + { + return $this->services()[$this->currentService] ?? null; + } + + public function totalServices(): int + { + return count($this->pageState->wsdl()->services->items); + } + + public function totalFilteredServices(): int + { + return count($this->services()); + } + + + public function up(): void + { + $this->currentService = (int) max([0, $this->currentService - 1]); + $this->trackTextAreaState(); + } + + public function down(): void + { + $this->currentService = (int) min([count($this->services()) - 1, $this->currentService + 1]); + $this->trackTextAreaState(); + } + + public function handle(Event $event): void + { + $this->textAreaState->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + default => null, + }; + } + } +} diff --git a/src/Console/UI/Components/Wsdl/ServicesWidget.php b/src/Console/UI/Components/Wsdl/ServicesWidget.php new file mode 100644 index 0000000..1129e07 --- /dev/null +++ b/src/Console/UI/Components/Wsdl/ServicesWidget.php @@ -0,0 +1,55 @@ +direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(20), + Constraint::percentage(80), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Services (%s/%s)', + $state->totalFilteredServices(), + $state->totalServices(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $state->services(), + static fn (Service $info) => ListItem::new(Text::fromString($info->name)) + ) + ) + ->select($state->currentServiceIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + BlockWidget::default() + ->titles(Title::fromString('Details (scrollable)')) + ->borders(Borders::ALL) + ->widget(ScrollableTextArea::create($state->textAreaState)), + ); + } +} diff --git a/src/Console/UI/EventBlocker.php b/src/Console/UI/EventBlocker.php new file mode 100644 index 0000000..5e10eb1 --- /dev/null +++ b/src/Console/UI/EventBlocker.php @@ -0,0 +1,8 @@ +direction(Direction::Vertical) + ->constraints( + Constraint::min(3), + Constraint::percentage(100), + Constraint::min(3), + ) + ->widgets( + Navigation::create($state), + $state->currentPage->build(), + Help::create(...$state->currentPage->help()), + ); + } +} diff --git a/src/Console/UI/Page.php b/src/Console/UI/Page.php new file mode 100644 index 0000000..ba208dc --- /dev/null +++ b/src/Console/UI/Page.php @@ -0,0 +1,17 @@ + InformationType::Type, + InformationType::Type => InformationType::Meta, + }; + } +} diff --git a/src/Console/UI/Page/MethodsPage.php b/src/Console/UI/Page/MethodsPage.php new file mode 100644 index 0000000..b5168d8 --- /dev/null +++ b/src/Console/UI/Page/MethodsPage.php @@ -0,0 +1,181 @@ +pageState = new MethodsPageState($UIState); + } + + public function title(): string + { + return '[m]ethods'; + } + + public function navigationChar(): string + { + return 'm'; + } + + public function build(): Widget + { + $selectedMethod = $this->pageState->selectedMethod(); + $selectedParamName = $this->pageState->selectedMethodParamName(); + $selectedParamType = $this->pageState->selectedMethodParamType(); + + return GridWidget::default() + ->constraints(...filter_nulls([ + Constraint::percentage(100), + $this->pageState->searching() ? Constraint::min(3) : null, + ])) + ->widgets(...filter_nulls([ + GridWidget::default() + ->direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(25), + Constraint::min(3), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Methods (%s/%s)', + $this->pageState->totalFilteredMethods(), + $this->pageState->totalMethods(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $this->pageState->methods(), + static fn (Method $method) => ListItem::new(Text::fromString($method->getName())) + ) + ) + ->select($this->pageState->selectedMethodIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + $selectedMethod + ? GridWidget::default() + ->constraints( + ...( + $this->pageState->zoom !== null + ? [Constraint::percentage(100)] + : [ + Constraint::percentage(20), + Constraint::percentage(35), + Constraint::percentage(35), + ] + ) + ) + ->widgets(...filter_nulls([ + $this->pageState->renderWidgetAtZoom( + 1, + BlockWidget::default() + ->titles(Title::fromLine(Line::parse('1. Method signature'))) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + ParagraphWidget::fromText( + Text::fromString( + (new LongMethodFormatter())($selectedMethod) + ) + ), + ), + ), + $this->pageState->renderWidgetAtZoom( + 2, + BlockWidget::default() + ->titles(Title::fromLine(Line::parse('2. Method Metadata'))) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + MetaTable::create($selectedMethod->getMeta()) + ), + ), + $this->pageState->renderWidgetAtZoom( + 3, + BlockWidget::default() + ->titles( + Title::fromLine(Line::parse('3. ' . match(true) { + $selectedParamName !== null => '← Parameter ' . $selectedParamName . ' → ', + $selectedParamType !== null => '← Result type '.$selectedParamType->getName().' → ', + default => 'No parameters or result types found', + })) + ) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + $selectedParamType + ? MetaTable::create(match($this->pageState->informationType) { + InformationType::Meta => $selectedParamType->getMeta(), + InformationType::Type => $selectedParamType, + }) + : ParagraphWidget::fromText(Text::fromString('')) + ) + ), + ])) + : BlockWidget::default() + ->titles(Title::fromString('EMPTY')) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + ParagraphWidget::fromText( + Text::fromString('No method could be found') + ), + ), + ), + $this->pageState->search && $this->pageState->searching() ? Search::create($this->pageState->search) : null, + ])); + } + + public function handle(Event $event): void + { + $this->pageState->handle($event); + } + + public function isBlockingParentEvents(): bool + { + return $this->pageState->searching(); + } + + public function help(): array + { + return [ + Line::parse('↑'), + Line::parse('↓'), + Line::parse('←'), + Line::parse('→'), + Line::parse('Tab ('.$this->pageState->informationType->toggle()->value.')'), + Line::parse('/ (search)'), + ]; + } +} diff --git a/src/Console/UI/Page/MethodsPageState.php b/src/Console/UI/Page/MethodsPageState.php new file mode 100644 index 0000000..53eddc1 --- /dev/null +++ b/src/Console/UI/Page/MethodsPageState.php @@ -0,0 +1,203 @@ +selectedMethod; + } + + public function selectedMethod(): ?Method + { + return values($this->methods())[$this->selectedMethod] ?? null; + } + + public function selectedMethodParamName(): ?string + { + $selectedMethod = $this->selectedMethod(); + if (!$selectedMethod) { + return null; + } + + $params = values($selectedMethod->getParameters()); + $param = $params[$this->selectedMethodParam] ?? null; + + return $param?->getName(); + } + + public function selectedMethodParamType(): ?XsdType + { + $selectedMethod = $this->selectedMethod(); + if (!$selectedMethod) { + return null; + } + + $params = values($selectedMethod->getParameters()); + if ($this->selectedMethodParam >= count($params)) { + return $selectedMethod->getReturnType(); + } + + return $params[$this->selectedMethodParam]?->getType() ?? null; + } + + /** + * @return list + */ + public function methods(): array + { + $query = (string) $this->search?->query; + + return \Psl\Vec\sort( + filter( + $this->state->metadata->getMethods(), + static fn (Method $method) => !$query || mb_stripos($method->getName(), $query) !== false + ), + static fn (Method $a, Method $b) => $a->getName() <=> $b->getName() + ); + } + + public function totalMethods(): int + { + return count($this->state->metadata->getMethods()); + } + + public function totalFilteredMethods(): int + { + return count($this->methods()); + } + + public function up(): void + { + $this->selectedMethod = (int) max([$this->selectedMethod-1, 0]); + $this->selectedMethodParam = 0; + } + + public function down(): void + { + $this->selectedMethod = (int) min([$this->selectedMethod + 1, count($this->methods()) - 1]); + $this->selectedMethodParam = 0; + } + + public function left(): void + { + $selectedType = $this->selectedMethod(); + if (!$selectedType) { + return; + } + + $this->selectedMethodParam = (int) max([$this->selectedMethodParam - 1, 0]); + } + + public function right(): void + { + $selectedType = $this->selectedMethod(); + if (!$selectedType) { + return; + } + + $this->selectedMethodParam = (int) min([$this->selectedMethodParam + 1, count($selectedType->getParameters())]); + } + + public function searching(): bool + { + return $this->search !== null && $this->search->locked === false; + } + + public function reset(): void + { + $this->stopSearching(); + $this->zoom(null); + } + + public function startSearching(): void + { + $this->search ??= SearchState::empty(); + $this->search->unlock(); + } + + public function lockSearching(): void + { + if ($this->search) { + $this->search->lock(); + } + } + + public function stopSearching(): void + { + $this->search = null; + } + + public function toggleInformationType(): void + { + $this->informationType = $this->informationType->toggle(); + } + + public function zoom(?int $zoom): void + { + $this->zoom = $zoom; + } + + public function renderWidgetAtZoom(int $zoom, Widget $widget): Widget|null + { + return ($this->zoom === null || $this->zoom === $zoom) ? $widget : null; + } + + public function handle(Event $event): void + { + $this->search?->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + KeyCode::Left => $this->left(), + KeyCode::Right => $this->right(), + KeyCode::Esc => $this->reset(), + KeyCode::Enter => $this->lockSearching(), + KeyCode::Tab => $this->toggleInformationType(), + default => null, + }; + } + + if ($event instanceof Event\CharKeyEvent) { + match ($event->char) { + '/' => $this->startSearching(), + '0' => $this->zoom(null), + '1' => $this->zoom(1), + '2' => $this->zoom(2), + '3' => $this->zoom(3), + default => null, + }; + + // Reset selected method when searching on every key press: + if ($this->search) { + $this->selectedMethod = 0; + } + } + } +} diff --git a/src/Console/UI/Page/TypesPage.php b/src/Console/UI/Page/TypesPage.php new file mode 100644 index 0000000..19bd15d --- /dev/null +++ b/src/Console/UI/Page/TypesPage.php @@ -0,0 +1,182 @@ +pageState = new TypesPageState($UIState); + } + + public function title(): string + { + return '[t]ypes'; + } + + public function navigationChar(): string + { + return 't'; + } + + public function build(): Widget + { + $selectedType = $this->pageState->selectedType(); + $selectedProperty = $this->pageState->selectedTypeProp(); + + return GridWidget::default() + ->constraints(...filter_nulls([ + Constraint::percentage(100), + $this->pageState->searching() ? Constraint::min(3) : null, + ])) + ->widgets(...filter_nulls([ + GridWidget::default() + ->direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(25), + Constraint::min(3), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString(sprintf( + 'Types (%s/%s)', + $this->pageState->totalFilteredTypes(), + $this->pageState->totalTypes(), + ))) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map( + $this->pageState->types(), + static fn (Type $type) => ListItem::new(Text::fromString($type->getName())) + ) + ) + ->select($this->pageState->selectedTypeIndex()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + $selectedType + ? GridWidget::default() + ->constraints( + ...( + $this->pageState->zoom !== null + ? [Constraint::percentage(100)] + : [ + Constraint::percentage(30), + Constraint::percentage(35), + Constraint::percentage(35), + ] + ) + ) + ->widgets(...filter_nulls([ + $this->pageState->renderWidgetAtZoom( + 1, + BlockWidget::default() + ->titles(Title::fromLine(Line::parse('1. Type signature'))) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + ParagraphWidget::fromText( + Text::fromString( + (new LongTypeFormatter())($selectedType) + ) + ), + ) + ), + $this->pageState->renderWidgetAtZoom( + 2, + BlockWidget::default() + ->titles(Title::fromLine(Line::parse('2. Type metadata'))) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + MetaTable::create(match($this->pageState->informationType) { + InformationType::Meta => $selectedType->getXsdType()->getMeta(), + InformationType::Type => $selectedType->getXsdType(), + }) + ), + ), + $this->pageState->renderWidgetAtZoom( + 3, + BlockWidget::default() + ->titles( + Title::fromLine(Line::parse('3. ' . match(true) { + $selectedProperty !== null => '← Property ' . $selectedProperty->getName() . ' → ', + default => 'No properties', + })), + ) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + $selectedProperty + ? MetaTable::create(match($this->pageState->informationType) { + InformationType::Meta => $selectedProperty->getType()->getMeta(), + InformationType::Type => $selectedProperty->getType(), + }) + : ParagraphWidget::fromText(Text::fromString('')) + ), + ), + ])) + : BlockWidget::default() + ->titles(Title::fromString('EMPTY')) + ->borders(Borders::ALL) + ->padding(Padding::horizontal(1)) + ->widget( + ParagraphWidget::fromText( + Text::fromString('No type could be found') + ), + ), + ), + $this->pageState->search && $this->pageState->searching() ? Search::create($this->pageState->search) : null, + ])); + } + + public function handle(Event $event): void + { + $this->pageState->handle($event); + } + + public function isBlockingParentEvents(): bool + { + return $this->pageState->searching(); + } + + public function help(): array + { + return [ + Line::parse('↑'), + Line::parse('↓'), + Line::parse('←'), + Line::parse('→'), + Line::parse('Tab ('.$this->pageState->informationType->toggle()->value.')'), + Line::parse('/ (search)'), + ]; + } +} diff --git a/src/Console/UI/Page/TypesPageState.php b/src/Console/UI/Page/TypesPageState.php new file mode 100644 index 0000000..1780c5c --- /dev/null +++ b/src/Console/UI/Page/TypesPageState.php @@ -0,0 +1,185 @@ +selectedType; + } + + public function selectedType(): ?Type + { + return values($this->types())[$this->selectedType] ?? null; + } + + public function selectedTypeProp(): ?Property + { + $selectedType = $this->selectedType(); + if (!$selectedType) { + return null; + } + + return values($selectedType->getProperties())[$this->selectedTypeProp] ?? null; + } + + /** + * @return list + */ + public function types(): array + { + $query = (string) $this->search?->query; + + return \Psl\Vec\sort( + filter( + $this->state->metadata->getTypes(), + static fn (Type $type) => !$query || mb_stripos($type->getName(), $query) !== false + ), + static fn (Type $a, Type $b) => $a->getName() <=> $b->getName() + ); + } + + public function totalTypes(): int + { + return count($this->state->metadata->getTypes()); + } + + public function totalFilteredTypes(): int + { + return count($this->types()); + } + + public function up(): void + { + $this->selectedType = (int) max([$this->selectedType-1, 0]); + $this->selectedTypeProp = 0; + } + + public function down(): void + { + $this->selectedType = (int) min([$this->selectedType + 1, count($this->types()) - 1]); + $this->selectedTypeProp = 0; + } + + public function searching(): bool + { + return $this->search !== null && $this->search->locked === false; + } + + public function reset(): void + { + $this->stopSearching(); + $this->zoom(null); + } + + public function startSearching(): void + { + $this->search ??= SearchState::empty(); + $this->search->unlock(); + } + + public function lockSearching(): void + { + if ($this->search) { + $this->search->lock(); + } + } + + public function stopSearching(): void + { + $this->search = null; + } + + public function left(): void + { + $selectedType = $this->selectedType(); + if (!$selectedType) { + return; + } + + $this->selectedTypeProp = (int) max([$this->selectedTypeProp - 1, 0]); + } + + public function right(): void + { + $selectedType = $this->selectedType(); + if (!$selectedType) { + return; + } + + $this->selectedTypeProp = (int) min([$this->selectedTypeProp + 1, count($selectedType->getProperties()) - 1]); + } + + public function toggleInformationType(): void + { + $this->informationType = $this->informationType->toggle(); + } + + public function zoom(?int $zoom): void + { + $this->zoom = $zoom; + } + + public function renderWidgetAtZoom(int $zoom, Widget $widget): Widget|null + { + return ($this->zoom === null || $this->zoom === $zoom) ? $widget : null; + } + + public function handle(Event $event): void + { + $this->search?->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Down => $this->down(), + KeyCode::Up => $this->up(), + KeyCode::Left => $this->left(), + KeyCode::Right => $this->right(), + KeyCode::Esc => $this->reset(), + KeyCode::Enter => $this->lockSearching(), + KeyCode::Tab => $this->toggleInformationType(), + default => null, + }; + } + + if ($event instanceof Event\CharKeyEvent) { + match ($event->char) { + '/' => $this->startSearching(), + '0' => $this->zoom(null), + '1' => $this->zoom(1), + '2' => $this->zoom(2), + '3' => $this->zoom(3), + default => null, + }; + + // Reset selected type when searching on every key press: + if ($this->search) { + $this->selectedType = 0; + } + } + } +} diff --git a/src/Console/UI/Page/WsdlInfo.php b/src/Console/UI/Page/WsdlInfo.php new file mode 100644 index 0000000..a7da943 --- /dev/null +++ b/src/Console/UI/Page/WsdlInfo.php @@ -0,0 +1,30 @@ + $case) { + if ($case === $this) { + return $index; + } + } + + throw new InvalidArgumentException('There should always be a match'); + } + + public static function default(): self + { + return self::Services; + } +} diff --git a/src/Console/UI/Page/WsdlPage.php b/src/Console/UI/Page/WsdlPage.php new file mode 100644 index 0000000..9ca47a8 --- /dev/null +++ b/src/Console/UI/Page/WsdlPage.php @@ -0,0 +1,99 @@ +pageState = new WsdlPageState($UIState); + } + + public function title(): string + { + return '[w]sdl'; + } + + public function navigationChar(): string + { + return 'w'; + } + + public function build(): Widget + { + return GridWidget::default() + ->constraints(...filter_nulls([ + Constraint::percentage(100), + $this->pageState->searching() ? Constraint::min(3) : null, + ])) + ->widgets(...filter_nulls([ + GridWidget::default() + ->direction(Direction::Horizontal) + ->constraints( + Constraint::percentage(15), + Constraint::percentage(85), + ) + ->widgets( + BlockWidget::default() + ->titles(Title::fromString('WSDL section')) + ->borders(Borders::ALL) + ->widget( + ListWidget::default() + ->items( + ...map_with_key( + WsdlInfo::cases(), + static fn (int $index, WsdlInfo $info) => ListItem::new( + Text::fromLine(Line::parse(''.($index + 1).'. '. $info->value)) + ) + ) + ) + ->select($this->pageState->infoPart->index()) + ->highlightStyle(Style::default()->white()->onBlue()) + ), + $this->pageState->buildInfoWidget(), + ), + $this->pageState->search && $this->pageState->searching() ? Search::create($this->pageState->search) : null, + ])); + } + + public function handle(Event $event): void + { + $this->pageState->handle($event); + } + + public function isBlockingParentEvents(): bool + { + return $this->pageState->searching(); + } + + public function help(): array + { + return [ + Line::parse('↑'), + Line::parse('↓'), + Line::parse('/ (search)'), + ]; + } +} diff --git a/src/Console/UI/Page/WsdlPageState.php b/src/Console/UI/Page/WsdlPageState.php new file mode 100644 index 0000000..c84eab9 --- /dev/null +++ b/src/Console/UI/Page/WsdlPageState.php @@ -0,0 +1,117 @@ +bindingsState = new Wsdl\BindingsState($this); + $this->messagesState = new Wsdl\MessagesState($this); + $this->namespacesState = new Wsdl\NamespacesState($this); + $this->portTypesState = new Wsdl\PortTypesState($this); + $this->servicesState = new Wsdl\ServicesState($this); + } + + public function path(): string + { + return $this->state->wsdlPath; + } + + public function wsdl(): Wsdl1 + { + return $this->state->wsdl1; + } + + public function buildInfoWidget(): Widget + { + return match($this->infoPart) { + WsdlInfo::Services => Wsdl\ServicesWidget::create($this->servicesState), + WsdlInfo::PortTypes => Wsdl\PortTypesWidget::create($this->portTypesState), + WsdlInfo::Messages => Wsdl\MessagesWidget::create($this->messagesState), + WsdlInfo::Namespaces => Wsdl\NamespacesWidget::create($this->namespacesState), + WsdlInfo::Bindings => Wsdl\BindingsWidget::create($this->bindingsState), + }; + } + + public function searching(): bool + { + return $this->search !== null && $this->search->locked === false; + } + + public function reset(): void + { + $this->stopSearching(); + } + + public function startSearching(): void + { + $this->search ??= SearchState::empty(); + $this->search->unlock(); + } + + public function lockSearching(): void + { + if ($this->search) { + $this->search->lock(); + } + } + + public function stopSearching(): void + { + $this->search = null; + } + + public function handle(Event $event): void + { + $this->search?->handle($event); + + if ($event instanceof Event\CodedKeyEvent) { + match ($event->code) { + KeyCode::Esc => $this->reset(), + KeyCode::Enter => $this->lockSearching(), + default => null, + }; + } + + if ($event instanceof Event\CharKeyEvent) { + match ($event->char) { + '/' => $this->startSearching(), + default => null, + }; + + foreach (WsdlInfo::cases() as $index => $infoPart) { + if ($event->char === (string) ($index + 1)) { + $this->infoPart = $infoPart; + } + } + } + + match ($this->infoPart) { + WsdlInfo::Bindings => $this->bindingsState->handle($event), + WsdlInfo::Messages => $this->messagesState->handle($event), + WsdlInfo::Namespaces => $this->namespacesState->handle($event), + WsdlInfo::PortTypes => $this->portTypesState->handle($event), + WsdlInfo::Services => $this->servicesState->handle($event), + }; + } +} diff --git a/src/Console/UI/UIState.php b/src/Console/UI/UIState.php new file mode 100644 index 0000000..681dad7 --- /dev/null +++ b/src/Console/UI/UIState.php @@ -0,0 +1,83 @@ +, Page> + */ + public array $availablePages; + public bool $running = true; + + private function __construct( + string $wsdlPath, + Wsdl1 $wsdl1, + Metadata $metadata, + ) { + $this->wsdlPath = $wsdlPath; + $this->wsdl1 = $wsdl1; + $this->metadata = $metadata; + $this->availablePages = [ + Page\MethodsPage::class => new Page\MethodsPage($this), + Page\TypesPage::class => new Page\TypesPage($this), + Page\WsdlPage::class => new Page\WsdlPage($this), + ]; + $this->currentPage = $this->availablePages[Page\MethodsPage::class]; + } + + /** + * @param non-empty-string $wsdlPath + */ + public static function load( + string $wsdlPath, + WsdlLoader $loader, + ): self { + $wsdl = (new Wsdl1Reader($loader))($wsdlPath); + $metadataProvider = new Wsdl1MetadataProvider($wsdl); + $metadata = $metadataProvider->getMetadata(); + + return new self( + $wsdlPath, + $wsdl, + $metadata + ); + } + + public function handle(Event $event): void + { + $this->currentPage->handle($event); + + if ($event instanceof Event\CharKeyEvent && !$this->currentPage->isBlockingParentEvents()) { + foreach ($this->availablePages as $page) { + if ($event->char === $page->navigationChar()) { + $this->currentPage = $page; + return; + } + } + + match ($event->char) { + 'q' => $this->running = false, + default => null, + }; + } + + + // CTRL+C always exits ! + if ($event instanceof Event\CharKeyEvent && $event->char === 'c' && ($event->modifiers & KeyModifiers::CONTROL)) { + $this->running = false; + } + } +} diff --git a/src/Console/WsdlReaderConfigurator.php b/src/Console/WsdlReaderConfigurator.php index 60f3e56..56bf4d9 100644 --- a/src/Console/WsdlReaderConfigurator.php +++ b/src/Console/WsdlReaderConfigurator.php @@ -12,6 +12,7 @@ public static function configure(Application $application): void { $application->addCommands([ new Command\InspectCommand(), + new Command\InspectUICommand(), new Command\InspectMethodCommand(), new Command\InspectTypeCommand(), ]);