diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index aa98e01872d..0bc3792864c 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -1969,6 +1969,11 @@ public function visitStubFiles(Codebase $codebase, ?Progress $progress = null): $this->internal_stubs[] = $ext_mysqli_path; } + if (extension_loaded('decimal')) { + $ext_decimal_path = $dir_lvl_2 . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . 'decimal.phpstub'; + $this->internal_stubs[] = $ext_decimal_path; + } + foreach ($this->internal_stubs as $stub_path) { if (!file_exists($stub_path)) { throw new UnexpectedValueException('Cannot locate ' . $stub_path); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php index e95c17b3368..a04595bd38f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php @@ -36,6 +36,7 @@ use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TNull; use Psalm\Type\Atomic\TNumeric; +use Psalm\Type\Atomic\TNumericString; use Psalm\Type\Atomic\TPositiveInt; use Psalm\Type\Atomic\TTemplateParam; @@ -611,6 +612,49 @@ private static function analyzeOperands( return null; } + if ($parent instanceof PhpParser\Node\Expr\BinaryOp\Plus + || $parent instanceof PhpParser\Node\Expr\BinaryOp\Minus + || $parent instanceof PhpParser\Node\Expr\BinaryOp\Mul + || $parent instanceof PhpParser\Node\Expr\BinaryOp\Div + || $parent instanceof PhpParser\Node\Expr\BinaryOp\Mod + || $parent instanceof PhpParser\Node\Expr\BinaryOp\Pow + ) { + $non_decimal_type = null; + if ($left_type_part instanceof TNamedObject + && strtolower($left_type_part->value) === "decimal\\decimal" + ) { + $non_decimal_type = $right_type_part; + } elseif ($right_type_part instanceof TNamedObject + && strtolower($right_type_part->value) === "decimal\\decimal" + ) { + $non_decimal_type = $left_type_part; + } + if ($non_decimal_type !== null) { + if ($non_decimal_type instanceof TInt + || $non_decimal_type instanceof TNumericString + || $non_decimal_type instanceof TNamedObject + && strtolower($non_decimal_type->value) === "decimal\\decimal" + ) { + $result_type = Type::combineUnionTypes( + new Type\Union([new TNamedObject("Decimal\\Decimal")]), + $result_type + ); + } else { + if ($statements_source && IssueBuffer::accepts( + new InvalidOperand( + "Cannot add Decimal\\Decimal to {$non_decimal_type->getId()}", + new CodeLocation($statements_source, $parent) + ), + $statements_source->getSuppressedIssues() + )) { + // fall through + } + } + + return null; + } + } + if ($left_type_part instanceof Type\Atomic\TLiteralString) { if (preg_match('/^\-?\d+$/', $left_type_part->value)) { $left_type_part = new Type\Atomic\TLiteralInt((int) $left_type_part->value); diff --git a/stubs/decimal.phpstub b/stubs/decimal.phpstub new file mode 100644 index 00000000000..90b329dab7f --- /dev/null +++ b/stubs/decimal.phpstub @@ -0,0 +1,492 @@ +` operator. + * + * @param mixed $other + * + * @return int 0 if this decimal is considered is equal to $other, + * -1 if this decimal should be placed before $other, + * 1 if this decimal should be placed after $other. + */ + public function compareTo($other): int {} + + /** + * String representation. + * + * This method is equivalent to a cast to string, as well as `toString`. + * + * @return string the value of this decimal represented exactly, in either + * fixed or scientific form, depending on the value. + */ + public function __toString(): string {} + + /** + * JSON + * + * This method is only here to honour the interface, and is equivalent to + * `toString`. JSON does not have a decimal type so all decimals are encoded + * as strings in the same format as `toString`. + * + * @return string + */ + public function jsonSerialize() {} +} diff --git a/tests/BinaryOperationTest.php b/tests/BinaryOperationTest.php index 26f1dd05b5c..bf9d485d7d0 100644 --- a/tests/BinaryOperationTest.php +++ b/tests/BinaryOperationTest.php @@ -85,6 +85,75 @@ public function testGMPOperations(): void $this->assertSame($assertions, $actual_vars); } + public function testDecimalOperations(): void + { + if (!class_exists('Decimal\\Decimal')) { + $this->markTestSkipped('Cannot run test, base class "Decimal\\Decimal" does not exist!'); + } + + $this->addFile( + 'somefile.php', + ' 'Decimal\\Decimal', + '$b' => 'Decimal\\Decimal', + '$c' => 'Decimal\\Decimal', + '$d' => 'Decimal\\Decimal', + '$f' => 'Decimal\\Decimal', + '$g' => 'Decimal\\Decimal', + '$h' => 'Decimal\\Decimal', + '$i' => 'Decimal\\Decimal', + '$j' => 'Decimal\\Decimal', + '$k' => 'Decimal\\Decimal', + '$l' => 'Decimal\\Decimal', + '$m' => 'Decimal\\Decimal', + '$n' => 'Decimal\\Decimal', + '$o' => 'Decimal\\Decimal', + '$p' => 'Decimal\\Decimal', + '$q' => 'Decimal\\Decimal', + '$r' => 'Decimal\\Decimal', + '$s' => 'Decimal\\Decimal', + '$t' => 'Decimal\\Decimal', + ]; + + $context = new Context(); + + $this->analyzeFile('somefile.php', $context); + + $actual_vars = []; + foreach ($assertions as $var => $_) { + if (isset($context->vars_in_scope[$var])) { + $actual_vars[$var] = (string)$context->vars_in_scope[$var]; + } + } + + $this->assertSame($assertions, $actual_vars); + } + public function testStrictTrueEquivalence(): void { $config = Config::getInstance();