diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index 99753b5..b6481ff 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -131,17 +131,9 @@ jobs: - name: "Build acceptance tests with codeception" run: vendor/bin/codecept build - - name: "Run base acceptance tests with codeception" - run: vendor/bin/codecept run -v -g symfony-common + - name: "Run acceptance tests with codeception" + run: vendor/bin/codecept run -v -g symfony-common -g symfony-${{ matrix.symfony-version }} - - name: "Run Symfony 3 acceptance tests with codeception" - if: matrix.symfony-version == '3' - run: vendor/bin/codecept run -v -g symfony-3 - - - name: "Run symfony 4 acceptance tests with codeception" - if: matrix.symfony-version == '4' - run: vendor/bin/codecept run -v -g symfony-4 - - - name: "Run Symfony 5 acceptance tests with codeception" - if: matrix.symfony-version == '5' - run: vendor/bin/codecept run -v -g symfony-5 + - name: "Run acceptance tests with codeception PHP8 only tests" + run: vendor/bin/codecept run -v -g php-8 + if: matrix.php-version == '8.0' diff --git a/src/Handler/ConsoleHandler.php b/src/Handler/ConsoleHandler.php index 865d8b2..0e54ce5 100644 --- a/src/Handler/ConsoleHandler.php +++ b/src/Handler/ConsoleHandler.php @@ -25,6 +25,7 @@ use Psalm\Type\Atomic\TString; use Psalm\Type\Union; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use Webmozart\Assert\Assert; class ConsoleHandler implements AfterMethodCallAnalysisInterface { @@ -116,17 +117,20 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface */ private static function analyseArgument(array $args, StatementsSource $statements_source): void { - $identifier = self::getNodeIdentifier($args[0]->value); + $normalizedParams = self::normalizeArgumentParams($args); + + $identifier = self::getNodeIdentifier($normalizedParams['name']->value); if (!$identifier) { return; } - if (count($args) > 1) { + $modeParam = $normalizedParams['mode']; + if ($modeParam) { try { - $mode = self::getModeValue($args[1]->value); + $mode = self::getModeValue($modeParam->value); } catch (InvalidConsoleModeException $e) { IssueBuffer::accepts( - new InvalidConsoleArgumentValue(new CodeLocation($statements_source, $args[1]->value)), + new InvalidConsoleArgumentValue(new CodeLocation($statements_source, $modeParam->value)), $statements_source->getSuppressedIssues() ); @@ -144,10 +148,10 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface $returnTypes = new Union([new TString(), new TNull()]); } - if (isset($args[3])) { - $defaultArg = $args[3]; + $defaultParam = $normalizedParams['default']; + if ($defaultParam) { $returnTypes->removeType('null'); - if ($defaultArg->value instanceof Expr\ConstFetch && 'null' === $defaultArg->value->name->parts[0]) { + if ($defaultParam->value instanceof Expr\ConstFetch && 'null' === $defaultParam->value->name->parts[0]) { $returnTypes->addType(new TNull()); } } @@ -160,7 +164,9 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface */ private static function analyseOption(array $args, StatementsSource $statements_source): void { - $identifier = self::getNodeIdentifier($args[0]->value); + $normalizedParams = self::normalizeOptionParams($args); + + $identifier = self::getNodeIdentifier($normalizedParams['name']->value); if (!$identifier) { return; } @@ -169,12 +175,13 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface $identifier = substr($identifier, 2); } - if (isset($args[2])) { + $modeOption = $normalizedParams['mode']; + if ($modeOption) { try { - $mode = self::getModeValue($args[2]->value); + $mode = self::getModeValue($modeOption->value); } catch (InvalidConsoleModeException $e) { IssueBuffer::accepts( - new InvalidConsoleOptionValue(new CodeLocation($statements_source, $args[2]->value)), + new InvalidConsoleOptionValue(new CodeLocation($statements_source, $modeOption->value)), $statements_source->getSuppressedIssues() ); @@ -186,11 +193,11 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface $returnTypes = new Union([new TString(), new TNull()]); - if (isset($args[4])) { - $defaultArg = $args[4]; + $defaultParam = $normalizedParams['default']; + if ($defaultParam) { $returnTypes->removeType('null'); - if ($defaultArg->value instanceof Expr\ConstFetch) { - switch ($defaultArg->value->name->parts[0]) { + if ($defaultParam->value instanceof Expr\ConstFetch) { + switch ($defaultParam->value->name->parts[0]) { case 'null': $returnTypes->addType(new TNull()); break; @@ -217,6 +224,46 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface self::$options[$identifier] = $returnTypes; } + /** + * @param array $args + * + * @psalm-return array{name: Arg, shortcut: ?Arg, mode: ?Arg, description: ?Arg, default: ?Arg} + */ + private static function normalizeOptionParams(array $args): array + { + return self::normalizeParams(['name', 'shortcut', 'mode', 'description', 'default'], $args); + } + + /** + * @param array $args + * + * @psalm-return array{name: Arg, mode: ?Arg, description: ?Arg, default: ?Arg} + */ + private static function normalizeArgumentParams(array $args): array + { + return self::normalizeParams(['name', 'mode', 'description', 'default'], $args); + } + + private static function normalizeParams(array $params, array $args): array + { + $result = array_fill_keys($params, null); + foreach ($args as $arg) { + if ($arg->name) { + $name = $arg->name->name; + + $key = array_search($name, $params); + Assert::integer($key); + $params = array_slice($params, $key + 1); + } else { + $name = array_shift($params); + } + + $result[$name] = $arg; + } + + return $result; + } + /** * @param mixed $mode */ diff --git a/tests/acceptance/acceptance/ConsoleArgument.feature b/tests/acceptance/acceptance/console/ConsoleArgument.feature similarity index 100% rename from tests/acceptance/acceptance/ConsoleArgument.feature rename to tests/acceptance/acceptance/console/ConsoleArgument.feature diff --git a/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature b/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature new file mode 100644 index 0000000..4f85d23 --- /dev/null +++ b/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature @@ -0,0 +1,38 @@ +@symfony-common @php-8 +Feature: ConsoleArgument named arguments with PHP8 + + Background: + Given I have Symfony plugin enabled + And I have the following code preamble + """ + addArgument('test', default: 'test'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @psalm-trace $argument */ + $argument = $input->getArgument('test'); + + return 0; + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $argument: string | + And I see no other errors diff --git a/tests/acceptance/acceptance/ConsoleOption.feature b/tests/acceptance/acceptance/console/ConsoleOption.feature similarity index 100% rename from tests/acceptance/acceptance/ConsoleOption.feature rename to tests/acceptance/acceptance/console/ConsoleOption.feature diff --git a/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature b/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature new file mode 100644 index 0000000..1f4d14c --- /dev/null +++ b/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature @@ -0,0 +1,39 @@ +@symfony-common @php-8 +Feature: ConsoleOption named arguments with PHP8 + + Background: + Given I have Symfony plugin enabled + And I have the following code preamble + """ + addOption('test', mode: InputOption::VALUE_REQUIRED, default: 'test'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + /** @psalm-trace $string */ + $string = $input->getOption('test'); + + return 0; + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $string: string | + And I see no other errors