├── tests └── .gitkeep ├── .env ├── .gitignore ├── src ├── SourceabilityConsoleToolbarBundle.php ├── Console │ ├── Model │ │ └── ToolbarCell.php │ ├── IndentedConsoleOutput.php │ └── ProfilerToolbarRenderer.php ├── Resources │ └── config │ │ └── services.yaml ├── DependencyInjection │ ├── SourceabilityConsoleToolbarExtension.php │ └── Configuration.php ├── Behat │ ├── SymfonyToolbarExtension.php │ └── Listener │ │ └── ProfilerToolbarListener.php ├── Profiler │ └── RecentProfileLoader.php ├── PHPUnit │ └── ConsoleToolbarExtension.php └── EventListener │ └── ConsoleToolbarListener.php ├── .editorconfig ├── docker-compose.yml ├── Dockerfile ├── phpstan.neon ├── LICENSE ├── Makefile ├── composer.json ├── ecs.php ├── .yamllint.yaml ├── .github └── workflows │ └── default.yaml └── README.md /tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DEV_PHP_VERSION=8.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /src/SourceabilityConsoleToolbarBundle.php: -------------------------------------------------------------------------------- 1 | text = $text; 20 | $this->color = $color; 21 | } 22 | 23 | public function getText(): string 24 | { 25 | return $this->text; 26 | } 27 | 28 | public function getColor(): ?string 29 | { 30 | return $this->color; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/IndentedConsoleOutput.php: -------------------------------------------------------------------------------- 1 | spaces = $spaces; 19 | } 20 | 21 | /** 22 | * @param string $message 23 | * @param bool $newline 24 | */ 25 | protected function doWrite($message, $newline): void 26 | { 27 | $prependBy = str_repeat(' ', $this->spaces); 28 | 29 | $message = $prependBy . $message; 30 | 31 | parent::doWrite($message, $newline); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sourceability 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Resources/config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | sourceability.console_toolbar.console.profiler_toolbar_renderer: 3 | class: 'Sourceability\ConsoleToolbarBundle\Console\ProfilerToolbarRenderer' 4 | public: true # For behat extension DI 5 | arguments: 6 | $router: '@router' 7 | $profiler: '@?profiler' 8 | $twig: '@twig' 9 | $templates: '%data_collector.templates%' 10 | $hiddenPanels: [] # Replaced by DI Extension 11 | $maxColumnWidth: 30 # Replaced by DI Extension 12 | 13 | sourceability.console_toolbar.profiler.recent_profile_loader: 14 | class: 'Sourceability\ConsoleToolbarBundle\Profiler\RecentProfileLoader' 15 | public: true # For behat extension DI 16 | arguments: 17 | $profiler: '@?profiler' 18 | 19 | sourceability.console_toolbar.event_listener.console_toolbar_listener: 20 | class: 'Sourceability\ConsoleToolbarBundle\EventListener\ConsoleToolbarListener' 21 | arguments: 22 | $toolbarRenderer: '@sourceability.console_toolbar.console.profiler_toolbar_renderer' 23 | $recentProfileLoader: '@sourceability.console_toolbar.profiler.recent_profile_loader' 24 | $kernel: '@kernel' 25 | tags: 26 | - { name: 'kernel.event_subscriber' } 27 | -------------------------------------------------------------------------------- /src/DependencyInjection/SourceabilityConsoleToolbarExtension.php: -------------------------------------------------------------------------------- 1 | $mergedConfig 14 | */ 15 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 16 | { 17 | /** @var array{toolbar: array{enabled: bool, hidden_panels: array, max_column_width: int}} $mergedConfig */ 18 | $toolbar = $mergedConfig['toolbar']; 19 | 20 | if (!$toolbar['enabled']) { 21 | return; 22 | } 23 | 24 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 25 | $loader->load('services.yaml'); 26 | 27 | $container 28 | ->getDefinition('sourceability.console_toolbar.console.profiler_toolbar_renderer') 29 | ->replaceArgument('$hiddenPanels', $toolbar['hidden_panels']) 30 | ->replaceArgument('$maxColumnWidth', $toolbar['max_column_width']) 31 | ; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 14 | 15 | $rootNode 16 | ->children() 17 | ->arrayNode('toolbar') 18 | ->canBeEnabled() 19 | ->children() 20 | ->integerNode('max_column_width') 21 | ->defaultValue(30) 22 | ->end() 23 | ->arrayNode('hidden_panels') 24 | ->prototype('scalar')->end() 25 | ->end() 26 | ->scalarNode('base_url') 27 | ->defaultValue('http://localhost') 28 | ->info('This is not used, please set the router request context instead.') 29 | ->setDeprecated('sourceability/console-toolbar-bundle', '0.1.3') 30 | ->end() 31 | ->end() 32 | ->end() 33 | ->end() 34 | ; 35 | 36 | return $treeBuilder; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | .DEFAULT_GOAL := help 3 | 4 | EXEC_YAMLLINT = yamllint 5 | ifeq (, $(shell which -s yamllint)) 6 | EXEC_YAMLLINT = docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/yamllint:1.26 7 | endif 8 | 9 | COMPOSE_EXEC ?= docker-compose exec 10 | 11 | # Prefix any command that should be run within the fpm docker container with $(EXEC_FPM) 12 | ifeq (, $(shell which docker-compose)) 13 | EXEC_PHP ?= 14 | else 15 | EXEC_PHP ?= $(COMPOSE_EXEC) php 16 | endif 17 | 18 | help: 19 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 20 | 21 | vendor: 22 | $(MAKE) composer-install 23 | 24 | .PHONY: composer-install 25 | composer-install: ## Install PHP dependencies 26 | $(EXEC_PHP) composer install --no-interaction --no-progress 27 | 28 | .PHONY: docker-sh 29 | docker-sh: ## Starts a bash session in the php container 30 | docker-compose exec php /bin/bash 31 | 32 | .PHONY: docker-up 33 | docker-up: ## Start Docker containers 34 | docker-compose up --detach --build --remove-orphans 35 | 36 | .PHONY: yamllint 37 | yamllint: ## Lints yaml files 38 | $(EXEC_YAMLLINT) -c .yamllint.yaml --strict . 39 | 40 | .PHONY: phpstan 41 | phpstan: vendor ## Static analysis 42 | $(EXEC_PHP) ./vendor/bin/phpstan 43 | 44 | .PHONY: cs 45 | cs: vendor ## Coding standards check 46 | $(EXEC_PHP) ./vendor/bin/ecs check 47 | 48 | .PHONY: cs 49 | cs-fix: vendor ## Coding standards fix 50 | $(EXEC_PHP) ./vendor/bin/ecs check --fix 51 | 52 | .PHONY: all 53 | all: phpstan yamllint cs phpunit ## Runs all test/lint targets 54 | -------------------------------------------------------------------------------- /src/Behat/SymfonyToolbarExtension.php: -------------------------------------------------------------------------------- 1 | $config 36 | */ 37 | public function load(ContainerBuilder $container, array $config): void 38 | { 39 | $definition = new Definition(ProfilerToolbarListener::class, [new Reference(SymfonyExtension::KERNEL_ID)]); 40 | $definition->addTag(EventDispatcherExtension::SUBSCRIBER_TAG); 41 | $container->setDefinition(ProfilerToolbarListener::class, $definition); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sourceability/console-toolbar-bundle", 3 | "type": "library", 4 | "description": "This bundle enables displaying the web toolbar in the console/terminal.", 5 | "homepage": "https://github.com/sourceability/console-toolbar-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Adrien Brault", 10 | "email": "adrien.brault@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.2 || ^8.0", 15 | "symfony/console": "^4.4 || ^5.0 || ^6.0", 16 | "symfony/dom-crawler": "^4.4 || ^5.0 || ^6.0", 17 | "symfony/framework-bundle": "^4.4 || ^5.0 || ^6.0", 18 | "symfony/twig-bundle": "^4.4 || ^5.0 || ^6.0", 19 | "symfony/web-profiler-bundle": "^4.4 || ^5.0 || ^6.0" 20 | }, 21 | "require-dev": { 22 | "behat/behat": "^3.7", 23 | "friends-of-behat/symfony-extension": "^2.0", 24 | "phpstan/phpstan": "^0.12.85 || ^1.0", 25 | "phpstan/phpstan-deprecation-rules": "^0.12.6 || ^1.0", 26 | "phpstan/phpstan-strict-rules": "^0.12.9 || ^1.0", 27 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", 28 | "symplify/easy-coding-standard": "^10.0" 29 | }, 30 | "config": { 31 | "preferred-install": "dist", 32 | "sort-packages": true 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Sourceability\\ConsoleToolbarBundle\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Sourceability\\ConsoleToolbarBundle\\Test\\": "tests/" 42 | } 43 | }, 44 | "support": { 45 | "issues": "https://github.com/sourceability/console-toolbar-bundle/issues", 46 | "source": "https://github.com/sourceability/console-toolbar-bundle" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | ruleWithConfiguration(ArraySyntaxFixer::class, [ 14 | 'syntax' => 'short', 15 | ]); 16 | 17 | $ecsConfig->import(SetList::PSR_12); 18 | $ecsConfig->import(SetList::PHP_CS_FIXER); 19 | $ecsConfig->import(SetList::PHP_CS_FIXER_RISKY); 20 | $ecsConfig->import(SetList::SYMPLIFY); 21 | $ecsConfig->import(SetList::SYMFONY); 22 | $ecsConfig->import(SetList::SYMFONY_RISKY); 23 | $ecsConfig->import(SetList::COMMON); 24 | $ecsConfig->import(SetList::CLEAN_CODE); 25 | 26 | $ecsConfig->paths([ 27 | __DIR__ . '/src', 28 | __DIR__ . '/tests' 29 | ]); 30 | 31 | $ecsConfig->skip([ 32 | MethodChainingIndentationFixer::class => [ 33 | __DIR__ . '/src/DependencyInjection/Configuration.php', 34 | ], 35 | MethodChainingNewlineFixer::class => [ 36 | __DIR__ . '/src/DependencyInjection/Configuration.php', 37 | ], 38 | YodaStyleFixer::class => [ 39 | __DIR__, 40 | ], 41 | NotOperatorWithSuccessorSpaceFixer::class => [ 42 | __DIR__, 43 | ], 44 | DeclareStrictTypesFixer::class => [ 45 | __DIR__, 46 | ], 47 | ]); 48 | }; 49 | -------------------------------------------------------------------------------- /src/Profiler/RecentProfileLoader.php: -------------------------------------------------------------------------------- 1 | profiler = $profiler; 18 | } 19 | 20 | /** 21 | * @return Profile[] 22 | */ 23 | public function loadSince(?int $startTimestamp): array 24 | { 25 | if (null === $this->profiler) { 26 | return []; 27 | } 28 | 29 | if (null !== $startTimestamp) { 30 | $startTimestamp = (string) $startTimestamp; 31 | } else { 32 | $startTimestamp = ''; 33 | } 34 | 35 | $newProfiles = $this->profiler->find('', '', (string) 100, '', $startTimestamp, ''); 36 | 37 | $profiles = []; 38 | foreach ($newProfiles as $newProfile) { 39 | $profile = $this->profiler->loadProfile($newProfile['token']); 40 | 41 | if (null === $profile) { 42 | continue; 43 | } 44 | 45 | $profiles[] = $profile; 46 | } 47 | 48 | return $profiles; 49 | } 50 | 51 | public function countSince(?int $startTimestamp): int 52 | { 53 | if (null === $this->profiler) { 54 | return 0; 55 | } 56 | 57 | if (null !== $startTimestamp) { 58 | $startTimestamp = (string) $startTimestamp; 59 | } else { 60 | $startTimestamp = ''; 61 | } 62 | 63 | $newProfiles = $this->profiler->find('', '', (string) 100, '', $startTimestamp, ''); 64 | 65 | return \count($newProfiles); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | # From https://github.com/ergebnis/php-library-template/blob/b5f31f245704ffac244c5b08231f417a5084c84f/.yamllint.yaml 2 | extends: 'default' 3 | 4 | ignore: | 5 | vendor/ 6 | 7 | rules: 8 | braces: 9 | max-spaces-inside-empty: 0 10 | max-spaces-inside: 1 11 | min-spaces-inside-empty: 0 12 | min-spaces-inside: 1 13 | brackets: 14 | max-spaces-inside-empty: 0 15 | max-spaces-inside: 0 16 | min-spaces-inside-empty: 0 17 | min-spaces-inside: 0 18 | colons: 19 | max-spaces-after: 1 20 | max-spaces-before: 0 21 | commas: 22 | max-spaces-after: 1 23 | max-spaces-before: 0 24 | min-spaces-after: 1 25 | comments: 26 | ignore-shebangs: true 27 | min-spaces-from-content: 1 28 | require-starting-space: true 29 | comments-indentation: 'enable' 30 | document-end: 31 | present: false 32 | document-start: 33 | present: false 34 | indentation: 35 | check-multi-line-strings: false 36 | indent-sequences: true 37 | spaces: 4 38 | empty-lines: 39 | max-end: 0 40 | max-start: 0 41 | max: 1 42 | empty-values: 43 | forbid-in-block-mappings: true 44 | forbid-in-flow-mappings: true 45 | hyphens: 46 | max-spaces-after: 3 47 | key-duplicates: 'enable' 48 | key-ordering: 'disable' 49 | line-length: 'disable' 50 | new-line-at-end-of-file: 'enable' 51 | new-lines: 52 | type: 'unix' 53 | octal-values: 54 | forbid-implicit-octal: true 55 | quoted-strings: 56 | quote-type: 'single' 57 | required: false 58 | trailing-spaces: 'enable' 59 | truthy: 60 | allowed-values: 61 | - 'false' 62 | - 'true' 63 | 64 | yaml-files: 65 | - '*.yaml' 66 | - '*.yml' 67 | -------------------------------------------------------------------------------- /.github/workflows/default.yaml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: # yamllint disable-line rule:truthy 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | checks: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php_version: 17 | - '7.2' 18 | - '7.3' 19 | - '7.4' 20 | - '8.0' 21 | symfony_version: 22 | - '4.4' 23 | - '5.0' 24 | - '5.1' 25 | - '5.2' 26 | - '5.3' 27 | exclude: 28 | - php_version: '8.0' 29 | symfony_version: '5.0' 30 | include: 31 | - php_version: '8.0' 32 | symfony_version: '6.0' 33 | - php_version: '8.1' 34 | symfony_version: '6.0' 35 | - php_version: '8.1' 36 | symfony_version: '6.1' 37 | 38 | name: 'PHP ${{ matrix.php_version }} - Symfony ${{ matrix.symfony_version }}' 39 | 40 | steps: 41 | - uses: actions/checkout@v2 42 | 43 | - 44 | uses: shivammathur/setup-php@v2 45 | with: 46 | php-version: ${{ matrix.php_version }} 47 | coverage: none 48 | 49 | # See https://github.com/actions/cache/blob/main/examples.md#php---composer 50 | - name: Get Composer Cache Directory 51 | id: composer-cache 52 | run: | 53 | echo "::set-output name=dir::$(composer config cache-files-dir)" 54 | - uses: actions/cache@v2 55 | with: 56 | path: ${{ steps.composer-cache.outputs.dir }} 57 | key: ${{ runner.os }}-php${{ matrix.php_version }}-symfony${{ matrix.symfony_version }}-composer-${{ hashFiles('**/composer.json') }} 58 | restore-keys: | 59 | ${{ runner.os }}-composer- 60 | 61 | - name: "Install symfony/flex for SYMFONY_REQUIRE" 62 | run: | 63 | composer global config --no-plugins allow-plugins.symfony/flex true 64 | composer global require --no-progress --no-scripts --no-plugins symfony/flex 65 | 66 | - name: 'Install dependencies' 67 | run: SYMFONY_REQUIRE='${{ matrix.symfony_version }}.*' composer update ${{ matrix.composer-flags }} --prefer-dist --prefer-dist 68 | 69 | - run: vendor/bin/phpstan 70 | - run: vendor/bin/ecs check 71 | -------------------------------------------------------------------------------- /src/PHPUnit/ConsoleToolbarExtension.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private $profileTokensShown = []; 29 | 30 | /** 31 | * @var bool 32 | */ 33 | private $alwaysShow; 34 | 35 | /** 36 | * @var int 37 | */ 38 | private $indentSpaces; 39 | 40 | public function __construct(bool $alwaysShow = true, int $indent = 4) 41 | { 42 | parent::__construct(null, [], ''); 43 | 44 | $this->alwaysShow = $alwaysShow; 45 | $this->indentSpaces = $indent; 46 | } 47 | 48 | public function executeBeforeTest(string $test): void 49 | { 50 | $this->lastProfileTimestamp = time(); 51 | $this->profileTokensShown = []; 52 | } 53 | 54 | public function executeAfterTest(string $test, float $time): void 55 | { 56 | $toolbar = (bool) getenv('TOOLBAR'); 57 | 58 | if (!$this->alwaysShow 59 | && !$toolbar 60 | ) { 61 | return; 62 | } 63 | 64 | $kernel = self::bootKernel(); 65 | 66 | $recentProfileLoader = $kernel->getContainer() 67 | ->get('sourceability.console_toolbar.profiler.recent_profile_loader') 68 | ; 69 | $profilerToolbarRenderer = $kernel->getContainer() 70 | ->get('sourceability.console_toolbar.console.profiler_toolbar_renderer') 71 | ; 72 | 73 | \assert($recentProfileLoader instanceof RecentProfileLoader); 74 | \assert($profilerToolbarRenderer instanceof ProfilerToolbarRenderer); 75 | 76 | $profiles = $recentProfileLoader->loadSince($this->lastProfileTimestamp); 77 | 78 | $profiles = array_filter( 79 | $profiles, 80 | function (Profile $newProfile): bool { 81 | return !\in_array($newProfile->getToken(), $this->profileTokensShown, true); 82 | } 83 | ); 84 | 85 | if (\count($profiles) > 0) { 86 | $output = new IndentedConsoleOutput($this->indentSpaces); 87 | $output->writeln(''); // make sure the table is fully displayed/aligned 88 | 89 | $profilerToolbarRenderer->render($output, $profiles); 90 | } 91 | 92 | foreach ($profiles as $profile) { 93 | $this->lastProfileTimestamp = max($this->lastProfileTimestamp ?? 0, $profile->getTime()); 94 | $this->profileTokensShown[] = $profile->getToken(); 95 | } 96 | 97 | self::ensureKernelShutdown(); // make sure we don't interfere with WebTestCase as the kernel is shared 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/EventListener/ConsoleToolbarListener.php: -------------------------------------------------------------------------------- 1 | toolbarRenderer = $toolbarRenderer; 42 | $this->recentProfileLoader = $recentProfileLoader; 43 | $this->kernel = $kernel; 44 | } 45 | 46 | /** 47 | * @return array> 48 | */ 49 | public static function getSubscribedEvents() 50 | { 51 | return [ 52 | ConsoleEvents::COMMAND => 'onCommand', 53 | ConsoleEvents::TERMINATE => ['onTerminate', -1024], 54 | ]; 55 | } 56 | 57 | public function onCommand(ConsoleCommandEvent $event): void 58 | { 59 | $command = $event->getCommand(); 60 | 61 | if (null === $command 62 | || null === $command->getApplication() 63 | ) { 64 | return; 65 | } 66 | 67 | // See https://github.com/symfony/symfony/pull/15938 68 | 69 | $toolbarOption = new InputOption( 70 | 'toolbar', 71 | null, 72 | InputOption::VALUE_NONE, 73 | 'Display the symfony profiler toolbar', 74 | null 75 | ); 76 | 77 | $definitions = [ 78 | $command->getApplication() 79 | ->getDefinition(), 80 | $command->getDefinition(), // because \Symfony\Component\Console\Command\Command::mergeApplicationDefinition has already been called 81 | ]; 82 | foreach ($definitions as $definition) { 83 | $definition->addOption($toolbarOption); 84 | } 85 | } 86 | 87 | public function onTerminate(ConsoleTerminateEvent $event): void 88 | { 89 | $input = $event->getInput(); 90 | $output = $event->getOutput(); 91 | 92 | $displayToolbar = $input->hasOption('toolbar') && (bool) $input->getOption('toolbar'); 93 | 94 | if (!$displayToolbar) { 95 | return; 96 | } 97 | 98 | $profiles = $this->recentProfileLoader->loadSince((int) $this->kernel->getStartTime()); 99 | 100 | if (\count($profiles) < 1) { 101 | // kind of weird, maybe deserves an error/exception ? 102 | return; 103 | } 104 | 105 | if ($output instanceof ConsoleOutputInterface) { 106 | // If possible, output to stderr to not mess with simple pipes 107 | $output = $output->getErrorOutput(); 108 | } 109 | 110 | $this->toolbarRenderer->render($output, $profiles); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sourceability/console-toolbar-bundle 2 | 3 | Render the symfony profiler toolbar in your terminal. 4 | 5 | Screen Shot 2021-05-18 at 17 52 13 6 | 7 | Each panel links to the corresponding web profiler page. 8 | Make sure to use a [terminal that support hyperlinks][hyperlink_terminals] to leverage this feature. 9 | 10 | ## Installation 11 | 12 | Install the bundle using composer: 13 | 14 | ```sh 15 | $ composer require --dev sourceability/console-toolbar-bundle 16 | ``` 17 | 18 | Enable the bundle by updating `config/bundles.php`: 19 | 20 | ```php 21 | return [ 22 | Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], 23 | // ... 24 | FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['dev' => true, 'test' => true], 25 | Sourceability\ConsoleToolbarBundle\SourceabilityConsoleToolbarBundle::class => ['dev' => true, 'test' => true], 26 | ]; 27 | ``` 28 | 29 | Configure the bundle in `config/packages/{dev,test}/sourceability_console_toolbar.yaml`: 30 | 31 | ```yaml 32 | sourceability_console_toolbar: 33 | toolbar: 34 | hidden_panels: 35 | - config 36 | - form 37 | - validator 38 | - logger 39 | ``` 40 | 41 | If your application is not exposed at `http://localhost` exactly, make sure that 42 | [you've configured the router request context][symfony_doc_request_context] for your environment. 43 | 44 | By default, the profiler does not always run in the `test` environment. 45 | You can enable it like this: 46 | 47 | ```diff 48 | --- a/config/packages/test/web_profiler.yaml 49 | +++ b/config/packages/test/web_profiler.yaml 50 | @@ -3,4 +3,4 @@ web_profiler: 51 | intercept_redirects: false 52 | 53 | framework: 54 | - profiler: { collect: false } 55 | + profiler: { enabled:true, collect: true, only_exceptions: false } 56 | ``` 57 | 58 | Also add web profiler routes in `config/routes/test/web_profiler.yaml` 59 | 60 | ```yaml 61 | web_profiler_wdt: 62 | resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' 63 | prefix: /_wdt 64 | 65 | web_profiler_profiler: 66 | resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' 67 | prefix: /_profiler 68 | ``` 69 | 70 | ## Behat 71 | 72 | This bundle becomes really useful when writing/debugging behat scenarios. 73 | 74 | First enable the behat extension by adding the following to your behat configuration: 75 | 76 | ```yaml 77 | default: 78 | extensions: 79 | FriendsOfBehat\SymfonyExtension: ~ 80 | Sourceability\ConsoleToolbarBundle\Behat\SymfonyToolbarExtension: ~ 81 | ``` 82 | 83 | This will display the console toolbar whenever a new symfony profile is detected: 84 | 85 | Screen Shot 2021-05-18 at 17 52 13 86 | 87 | ## PHPUnit 88 | 89 | Add the following to your `phpunit.xml` configuration: 90 | 91 | ```xml 92 | 93 | 94 | 95 | false 96 | 4 97 | 98 | 99 | 100 | ``` 101 | 102 | Screen Shot 2021-05-18 at 17 46 52 103 | 104 | ## Console 105 | 106 | `bin/console` now has a new global option `--toolbar`: 107 | 108 | Screen Shot 2021-05-18 at 18 02 22 109 | 110 | This feature requires [sourceability/instrumentation][sourceability_instrumentation] with the following bundle configuration: 111 | 112 | ```yaml 113 | sourceability_instrumentation: 114 | profilers: 115 | symfony: 116 | enabled: true 117 | listeners: 118 | command: 119 | enabled: true 120 | ``` 121 | 122 | [hyperlink_terminals]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda 123 | [sourceability_instrumentation]: https://github.com/sourceability/instrumentation 124 | [symfony_doc_request_context]: https://symfony.com/doc/4.4/routing.html#generating-urls-in-commands 125 | -------------------------------------------------------------------------------- /src/Behat/Listener/ProfilerToolbarListener.php: -------------------------------------------------------------------------------- 1 | 55 | */ 56 | private $profileTokensShown = []; 57 | 58 | public function __construct(KernelInterface $kernel) 59 | { 60 | $recentProfileLoader = $kernel->getContainer() 61 | ->get('sourceability.console_toolbar.profiler.recent_profile_loader') 62 | ; 63 | $profilerToolbarRenderer = $kernel->getContainer() 64 | ->get('sourceability.console_toolbar.console.profiler_toolbar_renderer') 65 | ; 66 | $router = $kernel->getContainer() 67 | ->get('router') 68 | ; 69 | 70 | \assert($recentProfileLoader instanceof RecentProfileLoader); 71 | \assert($profilerToolbarRenderer instanceof ProfilerToolbarRenderer); 72 | \assert($router instanceof Router); 73 | 74 | $this->recentProfileLoader = $recentProfileLoader; 75 | $this->profilerToolbarRenderer = $profilerToolbarRenderer; 76 | $this->router = $router; 77 | $this->originalRouterContext = clone $this->router->getContext(); // clone is important here, we're making a copy 78 | } 79 | 80 | /** 81 | * @return array> 82 | */ 83 | public static function getSubscribedEvents() 84 | { 85 | return [ 86 | ScenarioTested::BEFORE => ['beforeScenario', 10], 87 | StepTested::BEFORE => ['beforeAfterStep', 10], 88 | StepTested::AFTER => ['beforeAfterStep', 10], 89 | ScenarioTested::AFTER => ['afterScenario', 10], 90 | ]; 91 | } 92 | 93 | public function beforeScenario(ScenarioTested $event): void 94 | { 95 | $this->beforeScenarioTimestamp = time(); 96 | $this->lastProfileTimestamp = time(); 97 | $this->profileTokensShown = []; 98 | } 99 | 100 | public function beforeAfterStep(StepTested $event): void 101 | { 102 | $profiles = $this->recentProfileLoader->loadSince($this->lastProfileTimestamp); 103 | 104 | $profiles = array_filter( 105 | $profiles, 106 | function (Profile $newProfile): bool { 107 | return !\in_array($newProfile->getToken(), $this->profileTokensShown, true); 108 | } 109 | ); 110 | 111 | if (\count($profiles) > 0) { 112 | $this->profilerToolbarRenderer->render(new ConsoleOutput(), $profiles); 113 | } 114 | 115 | foreach ($profiles as $profile) { 116 | $this->lastProfileTimestamp = max($this->lastProfileTimestamp ?? 0, $profile->getTime()); 117 | $this->profileTokensShown[] = $profile->getToken(); 118 | } 119 | } 120 | 121 | public function afterScenario(ScenarioTested $event): void 122 | { 123 | $from = $this->beforeScenarioTimestamp; 124 | $to = time(); 125 | 126 | $profileCount = $this->recentProfileLoader->countSince($from); 127 | 128 | if ($profileCount < 1) { 129 | return; 130 | } 131 | 132 | $profilerUrl = $this->generateUrlFixed(function () use ($from, $to): string { 133 | return $this->router->generate( 134 | '_profiler_search', 135 | [ 136 | 'start' => $from, 137 | 'end' => $to, 138 | 'limit' => 100, 139 | // required otherwise list has no items 140 | ], 141 | UrlGeneratorInterface::ABSOLUTE_URL 142 | ); 143 | }); 144 | 145 | $output = new ConsoleOutput(); 146 | $output->writeln( 147 | sprintf('%d profiles collected, Open profile list', $profileCount, $profilerUrl) 148 | ); 149 | } 150 | 151 | private function generateUrlFixed(callable $generateUrl): string 152 | { 153 | $context = $this->router->getContext(); 154 | 155 | // Otherwise generated urls will look like http://application_test/login 156 | // instead of http://localhost:8200/app_test.php/login 157 | $this->router->setContext($this->originalRouterContext); 158 | 159 | $result = $generateUrl(); 160 | 161 | $this->router->setContext($context); 162 | 163 | return $result; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Console/ProfilerToolbarRenderer.php: -------------------------------------------------------------------------------- 1 | 67 | */ 68 | private $templates; 69 | 70 | /** 71 | * @var array 72 | */ 73 | private $hiddenPanels; 74 | 75 | /** 76 | * @var int 77 | */ 78 | private $maxColumnWidth; 79 | 80 | /** 81 | * @param array $templates 82 | * @param array $hiddenPanels 83 | */ 84 | public function __construct( 85 | RouterInterface $router, 86 | Profiler $profiler, 87 | Environment $twig, 88 | array $templates, 89 | array $hiddenPanels, 90 | int $maxColumnWidth = 30 91 | ) { 92 | $this->router = $router; 93 | $this->profiler = $profiler; 94 | $this->twig = $twig; 95 | $this->templates = $templates; 96 | $this->hiddenPanels = $hiddenPanels; 97 | $this->maxColumnWidth = $maxColumnWidth; 98 | $this->originalRouterContext = clone $router->getContext(); // clone is important here, we're making a copy 99 | } 100 | 101 | /** 102 | * @param array $profiles 103 | */ 104 | public function render(OutputInterface $output, array $profiles): void 105 | { 106 | if (\count($profiles) < 1) { 107 | return; 108 | } 109 | 110 | // Sort by date ascending 111 | usort( 112 | $profiles, 113 | static function (Profile $left, Profile $right): int { 114 | return $left->getTime() <=> $right->getTime(); 115 | } 116 | ); 117 | 118 | $table = new Table($output); 119 | $table->setStyle('box'); 120 | 121 | $originalHeaders = $headers = ['Type', 'Name']; 122 | $rows = []; 123 | foreach ($profiles as $profile) { 124 | $webProfilerUrl = $this->generateUrlFixed(function () use ($profile): string { 125 | return $this->router->generate( 126 | '_profiler', 127 | [ 128 | 'token' => $profile->getToken(), 129 | ], 130 | UrlGeneratorInterface::ABSOLUTE_URL 131 | ); 132 | }); 133 | 134 | $rows[] = $this->renderRow($profile, $webProfilerUrl, $headers, $originalHeaders); 135 | $rows[] = new TableSeparator(); 136 | } 137 | 138 | $table->setHeaders($headers); 139 | foreach ($headers as $headerIndex => $header) { 140 | $table->setColumnMaxWidth($headerIndex, $this->maxColumnWidth); 141 | } 142 | 143 | array_pop($rows); // remove last table sep 144 | 145 | $table->setRows($rows); 146 | $table->render(); 147 | } 148 | 149 | /** 150 | * @param array $headers 151 | * @param array $originalHeaders 152 | * 153 | * @return array 154 | */ 155 | private function renderRow(Profile $profile, string $webProfilerUrl, array &$headers, array $originalHeaders): array 156 | { 157 | $row = [ 158 | $this->link($profile->getMethod() ?? '', $webProfilerUrl), 159 | $this->link($this->urlRemoveBeforePath($profile->getUrl() ?? ''), $webProfilerUrl), 160 | ]; 161 | 162 | // make sure cells are aligned with headers 163 | $fillCount = \count($headers) - \count($originalHeaders); 164 | \assert($fillCount >= 0); 165 | $row = array_merge($row, array_fill(0, $fillCount, '')); 166 | 167 | $toolbarCells = $this->getWebToolbarCells($profile); 168 | $toolbarCells = array_diff_key($toolbarCells, array_flip($this->hiddenPanels)); 169 | 170 | foreach ($toolbarCells as $panel => $toolbarCell) { 171 | $headerName = $panel; 172 | 173 | if (mb_strlen($headerName) > $this->maxColumnWidth) { 174 | $headerName = substr($headerName, 0, $this->maxColumnWidth - 3) . '...'; 175 | } 176 | 177 | $panelIndex = array_search($headerName, $headers, true); 178 | 179 | if (false === $panelIndex) { 180 | $headers[] = $headerName; 181 | $panelIndex = \count($headers) - 1; 182 | } else { 183 | $panelIndex = (int) $panelIndex; 184 | } 185 | 186 | $row[$panelIndex] = $this->link( 187 | $toolbarCell->getText(), 188 | sprintf('%s?panel=%s', $webProfilerUrl, $panel), 189 | $toolbarCell->getColor() 190 | ); 191 | } 192 | 193 | return $row; 194 | } 195 | 196 | /** 197 | * @return array 198 | */ 199 | private function getWebToolbarCells(Profile $profile): array 200 | { 201 | $templateManager = new TemplateManager($this->profiler, $this->twig, $this->templates); 202 | 203 | $toolbar = $this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', [ 204 | 'request' => null, 205 | 'profile' => $profile, 206 | 'templates' => $templateManager->getNames($profile), 207 | 'profiler_url' => $profile->getUrl(), 208 | 'token' => $profile->getToken(), 209 | 'profiler_markup_version' => 2, 210 | // 1 = original toolbar, 2 = Symfony 2.8+ toolbar 211 | 'csp_script_nonce' => null, 212 | 'csp_style_nonce' => null, 213 | 'full_stack' => class_exists('Symfony\Bundle\FullStack'), 214 | ]); 215 | 216 | $crawler = new Crawler(); 217 | $crawler->addContent($toolbar); 218 | 219 | $panels = []; 220 | foreach ($crawler->filter('.sf-toolbar-block') as $toolbarBlock) { 221 | $toolbarBlock = new Crawler($toolbarBlock); 222 | 223 | $panelLink = $toolbarBlock->filter('a[href*="panel="]'); 224 | 225 | if ($panelLink->count() < 1) { 226 | continue; 227 | } 228 | 229 | $href = $panelLink->first() 230 | ->attr('href') 231 | ; 232 | 233 | if (null === $href) { 234 | continue; 235 | } 236 | 237 | $parsedUrl = parse_url($href); 238 | 239 | if (false === $parsedUrl 240 | || !\array_key_exists('query', $parsedUrl) 241 | ) { 242 | continue; 243 | } 244 | 245 | parse_str($parsedUrl['query'], $query); 246 | 247 | $panel = $query['panel']; 248 | if (!\is_string($panel)) { 249 | continue; 250 | } 251 | 252 | if (\array_key_exists($panel, $panels)) { 253 | // time has 2 "blocks", so let's no override response time with peak memory 254 | continue; 255 | } 256 | 257 | $matches = []; 258 | preg_match('#sf-toolbar-status-(?P[a-zA-Z]+)#', $toolbarBlock->html(), $matches); 259 | $color = $matches['color'] ?? null; 260 | 261 | if (!\in_array($color, self::VALID_COLORS, true)) { 262 | $color = 'default'; 263 | } 264 | 265 | $panels[$panel] = new ToolbarCell( 266 | $this->removeWhiteSpaces($toolbarBlock->filter('.sf-toolbar-icon')->text()), 267 | $color 268 | ); 269 | } 270 | 271 | return $panels; 272 | } 273 | 274 | private function link(string $text, string $url, ?string $color = null): string 275 | { 276 | $parts = []; 277 | 278 | if (mb_strlen($text) <= $this->maxColumnWidth) { 279 | $parts[] = sprintf('href=%s', $url); 280 | } 281 | 282 | if (null !== $color) { 283 | $parts[] = sprintf('fg=%s', $color); 284 | } 285 | 286 | if (\count($parts) < 1) { 287 | return $text; 288 | } 289 | 290 | return sprintf('<%s>%s', implode(';', $parts), $text); 291 | } 292 | 293 | private function urlRemoveBeforePath(string $url): string 294 | { 295 | return preg_replace('#^https?://(?:sa-web-profiler.localhost/|[^/]+)#', '', $url) ?? ''; 296 | } 297 | 298 | private function removeWhiteSpaces(string $text): string 299 | { 300 | return trim(preg_replace('/\s+/', ' ', $text) ?? ''); 301 | } 302 | 303 | private function generateUrlFixed(callable $generateUrl): string 304 | { 305 | $context = $this->router->getContext(); 306 | 307 | // Otherwise generated urls might look like http://application_test/login instead of http://localhost:8200/app_test.php/login 308 | $this->router->setContext($this->originalRouterContext); 309 | 310 | $result = $generateUrl(); 311 | 312 | $this->router->setContext($context); 313 | 314 | return $result; 315 | } 316 | } 317 | --------------------------------------------------------------------------------