diff --git a/Attribute/Argument.php b/Attribute/Argument.php index bca9a2e13..33b7a86b2 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -87,6 +87,8 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member) } if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { + // In case that the callback is declared as a static method `[Foo::class, 'methodName']` - yet it is not callable, + // while non-static method `[Foo $instance, 'methodName']` would be callable, we transform the callback on the fly into a non-static version. $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index ffbb10e8c..3198f26eb 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -143,10 +143,11 @@ public function process(ContainerBuilder $container): void } if ($description) { - $definition->addMethodCall('setDescription', [str_replace('%', '%%', $description)]); + $escapedDescription = str_replace('%', '%%', $description); + $definition->addMethodCall('setDescription', [$escapedDescription]); $container->register('.'.$id.'.lazy', LazyCommand::class) - ->setArguments([$commandName, $aliases, $description, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]); + ->setArguments([$commandName, $aliases, $escapedDescription, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]); $lazyCommandRefs[$id] = new Reference('.'.$id.'.lazy'); } diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 1f0cc1ca3..80e478f16 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -306,7 +306,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu if ($numMatches > 0 && -1 !== $ofs) { $ret = (string) $matches[$ofs]; // Echo out remaining chars for current match - $remainingCharacters = substr($ret, \strlen(trim($this->mostRecentlyEnteredValue($fullChoice)))); + $remainingCharacters = substr($ret, \strlen($this->mostRecentlyEnteredValue($fullChoice))); $output->write($remainingCharacters); $fullChoice .= $remainingCharacters; $i = (false === $encoding = mb_detect_encoding($fullChoice, null, true)) ? \strlen($fullChoice) : mb_strlen($fullChoice, $encoding); @@ -360,7 +360,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu if ($numMatches > 0 && -1 !== $ofs) { $cursor->savePosition(); // Write highlighted text, complete the partially entered response - $charactersEntered = \strlen(trim($this->mostRecentlyEnteredValue($fullChoice))); + $charactersEntered = \strlen($this->mostRecentlyEnteredValue($fullChoice)); $output->write(''.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).''); $cursor->restorePosition(); } diff --git a/Helper/SymfonyQuestionHelper.php b/Helper/SymfonyQuestionHelper.php index b452bf047..fb03fe615 100644 --- a/Helper/SymfonyQuestionHelper.php +++ b/Helper/SymfonyQuestionHelper.php @@ -31,7 +31,7 @@ protected function writePrompt(OutputInterface $output, Question $question): voi $default = $question->getDefault(); if ($question->isMultiline()) { - $text .= \sprintf(' (press %s to continue)', $this->getEofShortcut()); + $text .= \sprintf(' (press %s to continue)', $this->getEofShortcut($output)); } switch (true) { @@ -92,9 +92,9 @@ protected function writeError(OutputInterface $output, \Exception $error): void parent::writeError($output, $error); } - private function getEofShortcut(): string + private function getEofShortcut(OutputInterface $output): string { - if ('Windows' === \PHP_OS_FAMILY) { + if ('\\' === \DIRECTORY_SEPARATOR && !$output->isDecorated()) { return 'Ctrl+Z then Enter'; } diff --git a/Helper/TableStyle.php b/Helper/TableStyle.php index 74ac58925..bc941efc2 100644 --- a/Helper/TableStyle.php +++ b/Helper/TableStyle.php @@ -78,10 +78,11 @@ public function getPaddingChar(): string * * * ╔═══════════════╤══════════════════════════╤══════════════════╗ - * 1 ISBN 2 Title │ Author ║ - * ╠═══════════════╪══════════════════════════╪══════════════════╣ + * ║ ISBN │ Title │ Author ║ + * ╠═══════1═══════╪══════════════════════════╪══════════════════╣ * ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ * ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ + * ╟───────2───────┼──────────────────────────┼──────────────────╢ * ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ * ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ * ╚═══════════════╧══════════════════════════╧══════════════════╝ @@ -102,11 +103,10 @@ public function setHorizontalBorderChars(string $outside, ?string $inside = null * * * ╔═══════════════╤══════════════════════════╤══════════════════╗ - * ║ ISBN │ Title │ Author ║ - * ╠═══════1═══════╪══════════════════════════╪══════════════════╣ + * 1 ISBN 2 Title │ Author ║ + * ╠═══════════════╪══════════════════════════╪══════════════════╣ * ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ * ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ - * ╟───────2───────┼──────────────────────────┼──────────────────╢ * ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ * ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ * ╚═══════════════╧══════════════════════════╧══════════════════╝ diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index e9a2961af..e63c588f3 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -42,8 +42,15 @@ public function testCommandInputArgumentDefinition() #[Argument] ?string $firstName, #[Argument] string $lastName = '', #[Argument(description: 'Short argument description')] string $bio = '', + // In this test case, we declare the callback in static context, even when the method is NOT static. + // PHP doesn't allow using `$this` here, and the callback is later modified on-the-fly + // to be called on the instance instead, and this test case validates if this mechanism works. + // + // @see \Symfony\Component\Console\Attribute\Argument #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], ): int { + \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied + return 0; }); @@ -88,6 +95,8 @@ public function testCommandInputOptionDefinition() #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], #[Option] string|bool $opt = false, ): int { + \assert(null !== $this); // so PHP CS Fixer knows this callback is actually coupled with `$this` and `static_lambda` rule shall not be applied + return 0; }); diff --git a/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 953e5843c..790e31ab0 100644 --- a/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -329,6 +329,25 @@ public function testProcessInvokableCommand() $this->assertStringContainsString('usage1', $command->getUsages()[0]); } + public function testProcessCommandWithDescriptionWithpercentageSigns() + { + $container = new ContainerBuilder(); + $container + ->register( + 'description_with_percentage_signs_command', + DescriptionWithPercentageSignsCommand::class, + ) + ->addTag('console.command') + ; + $pass = new AddConsoleCommandPass(); + $pass->process($container); + + $command = $container->get('console.command_loader')->get('description-percentage-signs'); + + self::assertTrue($container->has('description_with_percentage_signs_command.command')); + self::assertSame('Just testing %percentage-signs%', $command->getDescription()); + } + public function testProcessInvokableSignalableCommand() { $container = new ContainerBuilder(); @@ -386,6 +405,14 @@ public function __invoke(): void } } +#[AsCommand(name: 'description-percentage-signs', description: 'Just testing %percentage-signs%')] +class DescriptionWithPercentageSignsCommand +{ + public function __invoke(): void + { + } +} + #[AsCommand(name: 'invokable-signalable', description: 'Just testing', help: 'The %command.name% help content.')] class InvokableSignalableCommand implements SignalableCommandInterface { diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 8d492c564..a78305bca 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -415,6 +415,24 @@ public function testAskWithAutocompleteWithMultiByteCharacter($character) $this->assertSame($character, $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question)); } + public function testAutocompleteWithSpaceAfterPartialMatch() + { + if (!Terminal::hasSttyAvailable()) { + $this->markTestSkipped('`stty` is required to test autocomplete functionality'); + } + + // a + $inputStream = $this->getInputStream("a \t\n"); + + $dialog = new QuestionHelper(); + $dialog->setHelperSet(new HelperSet([new FormatterHelper()])); + + $question = new ChoiceQuestion('Please select a choice', ['a test', 'another choice']); + $question->setMaxAttempts(1); + + $this->assertSame('a test', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question)); + } + public function testAutocompleteWithTrailingBackslash() { if (!Terminal::hasSttyAvailable()) { @@ -970,7 +988,7 @@ public function testExitCommandOnInputSIGINT(string $mode) } $p = new Process( - ['php', dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode], + ['php', \dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode], timeout: 2, // the process will auto shutdown if not killed by SIGINT, to prevent blocking ); $p->setPty(true);