• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PhpSpreadsheet / 21027532355

15 Jan 2026 10:11AM UTC coverage: 96.239% (+0.04%) from 96.198%
21027532355

Pull #4657

github

web-flow
Merge 52938a2eb into 48f2fe37d
Pull Request #4657: Handling Unions as Function Arguments

20 of 21 new or added lines in 1 file covered. (95.24%)

59 existing lines in 1 file now uncovered.

46288 of 48097 relevant lines covered (96.24%)

387.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.15
/src/PhpSpreadsheet/Calculation/Calculation.php
1
<?php
2

3
namespace PhpOffice\PhpSpreadsheet\Calculation;
4

5
use Composer\Pcre\Preg; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
6
use PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
7
use PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
8
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
9
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Operands;
10
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
11
use PhpOffice\PhpSpreadsheet\Calculation\Token\Stack;
12
use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
13
use PhpOffice\PhpSpreadsheet\Cell\Cell;
14
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
15
use PhpOffice\PhpSpreadsheet\Cell\DataType;
16
use PhpOffice\PhpSpreadsheet\DefinedName;
17
use PhpOffice\PhpSpreadsheet\NamedRange;
18
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
19
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
20
use PhpOffice\PhpSpreadsheet\Spreadsheet;
21
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
22
use ReflectionClassConstant;
23
use ReflectionMethod;
24
use ReflectionParameter;
25
use Throwable;
26
use TypeError;
27

28
class Calculation extends CalculationLocale
29
{
30
    /** Constants                */
31
    /** Regular Expressions        */
32
    //    Numeric operand
33
    const CALCULATION_REGEXP_NUMBER = '[-+]?\d*\.?\d+(e[-+]?\d+)?';
34
    //    String operand
35
    const CALCULATION_REGEXP_STRING = '"(?:[^"]|"")*"';
36
    //    Opening bracket
37
    const CALCULATION_REGEXP_OPENBRACE = '\(';
38
    //    Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
39
    const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
40
    //    Cell reference (cell or range of cells, with or without a sheet reference)
41
    const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
42
    // Used only to detect spill operator #
43
    const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
44
    //    Cell reference (with or without a sheet reference) ensuring absolute/relative
45
    const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])';
46
    const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\".(?:[^\"]|\"[^!])?\"))!)?(\$?[a-z]{1,3})):(?![.*])';
47
    const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])';
48
    //    Cell reference (with or without a sheet reference) ensuring absolute/relative
49
    //    Cell ranges ensuring absolute/relative
50
    const CALCULATION_REGEXP_COLUMNRANGE_RELATIVE = '(\$?[a-z]{1,3}):(\$?[a-z]{1,3})';
51
    const CALCULATION_REGEXP_ROWRANGE_RELATIVE = '(\$?\d{1,7}):(\$?\d{1,7})';
52
    //    Defined Names: Named Range of cells, or Named Formulae
53
    const CALCULATION_REGEXP_DEFINEDNAME = '((([^\s,!&%^\/\*\+<>=-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?([_\p{L}][_\p{L}\p{N}\.]*)';
54
    // Structured Reference (Fully Qualified and Unqualified)
55
    const CALCULATION_REGEXP_STRUCTURED_REFERENCE = '([\p{L}_\\\][\p{L}\p{N}\._]+)?(\[(?:[^\d\]+-])?)';
56
    //    Error
57
    const CALCULATION_REGEXP_ERROR = '\#[A-Z][A-Z0_\/]*[!\?]?';
58

59
    /** constants */
60
    const RETURN_ARRAY_AS_ERROR = 'error';
61
    const RETURN_ARRAY_AS_VALUE = 'value';
62
    const RETURN_ARRAY_AS_ARRAY = 'array';
63

64
    /** Preferable to use instance variable instanceArrayReturnType rather than this static property. */
65
    private static string $returnArrayAsType = self::RETURN_ARRAY_AS_VALUE;
66

67
    /** Preferable to use this instance variable rather than static returnArrayAsType */
68
    private ?string $instanceArrayReturnType = null;
69

70
    /**
71
     * Instance of this class.
72
     */
73
    private static ?Calculation $instance = null;
74

75
    /**
76
     * Instance of the spreadsheet this Calculation Engine is using.
77
     */
78
    private ?Spreadsheet $spreadsheet;
79

80
    /**
81
     * Calculation cache.
82
     *
83
     * @var mixed[]
84
     */
85
    private array $calculationCache = [];
86

87
    /**
88
     * Calculation cache enabled.
89
     */
90
    private bool $calculationCacheEnabled = true;
91

92
    private BranchPruner $branchPruner;
93

94
    private bool $branchPruningEnabled = true;
95

96
    /**
97
     * List of operators that can be used within formulae
98
     * The true/false value indicates whether it is a binary operator or a unary operator.
99
     */
100
    private const CALCULATION_OPERATORS = [
101
        '+' => true, '-' => true, '*' => true, '/' => true,
102
        '^' => true, '&' => true, '%' => false, '~' => false,
103
        '>' => true, '<' => true, '=' => true, '>=' => true,
104
        '<=' => true, '<>' => true, '∩' => true, '∪' => true,
105
        ':' => true,
106
    ];
107

108
    /**
109
     * List of binary operators (those that expect two operands).
110
     */
111
    private const BINARY_OPERATORS = [
112
        '+' => true, '-' => true, '*' => true, '/' => true,
113
        '^' => true, '&' => true, '>' => true, '<' => true,
114
        '=' => true, '>=' => true, '<=' => true, '<>' => true,
115
        '∩' => true, '∪' => true, ':' => true,
116
    ];
117

118
    /**
119
     * The debug log generated by the calculation engine.
120
     */
121
    private Logger $debugLog;
122

123
    private bool $suppressFormulaErrors = false;
124

125
    private bool $processingAnchorArray = false;
126

127
    /**
128
     * Error message for any error that was raised/thrown by the calculation engine.
129
     */
130
    public ?string $formulaError = null;
131

132
    /**
133
     * An array of the nested cell references accessed by the calculation engine, used for the debug log.
134
     */
135
    private CyclicReferenceStack $cyclicReferenceStack;
136

137
    /** @var mixed[] */
138
    private array $cellStack = [];
139

140
    /**
141
     * Current iteration counter for cyclic formulae
142
     * If the value is 0 (or less) then cyclic formulae will throw an exception,
143
     * otherwise they will iterate to the limit defined here before returning a result.
144
     */
145
    private int $cyclicFormulaCounter = 1;
146

147
    private string $cyclicFormulaCell = '';
148

149
    /**
150
     * Number of iterations for cyclic formulae.
151
     */
152
    public int $cyclicFormulaCount = 1;
153

154
    /**
155
     * Excel constant string translations to their PHP equivalents
156
     * Constant conversion from text name/value to actual (datatyped) value.
157
     */
158
    private const EXCEL_CONSTANTS = [
159
        'TRUE' => true,
160
        'FALSE' => false,
161
        'NULL' => null,
162
    ];
163

164
    public static function keyInExcelConstants(string $key): bool
20✔
165
    {
166
        return array_key_exists($key, self::EXCEL_CONSTANTS);
20✔
167
    }
168

169
    public static function getExcelConstants(string $key): bool|null
3✔
170
    {
171
        return self::EXCEL_CONSTANTS[$key];
3✔
172
    }
173

174
    /**
175
     *    Internal functions used for special control purposes.
176
     *
177
     * @var array<string, array<string, array<string>|string>>
178
     */
179
    private static array $controlFunctions = [
180
        'MKMATRIX' => [
181
            'argumentCount' => '*',
182
            'functionCall' => [Internal\MakeMatrix::class, 'make'],
183
        ],
184
        'NAME.ERROR' => [
185
            'argumentCount' => '*',
186
            'functionCall' => [ExcelError::class, 'NAME'],
187
        ],
188
        'WILDCARDMATCH' => [
189
            'argumentCount' => '2',
190
            'functionCall' => [Internal\WildcardMatch::class, 'compare'],
191
        ],
192
    ];
193

194
    public function __construct(?Spreadsheet $spreadsheet = null)
11,166✔
195
    {
196
        $this->spreadsheet = $spreadsheet;
11,166✔
197
        $this->cyclicReferenceStack = new CyclicReferenceStack();
11,166✔
198
        $this->debugLog = new Logger($this->cyclicReferenceStack);
11,166✔
199
        $this->branchPruner = new BranchPruner($this->branchPruningEnabled);
11,166✔
200
    }
201

202
    /**
203
     * Get an instance of this class.
204
     *
205
     * @param ?Spreadsheet $spreadsheet Injected spreadsheet for working with a PhpSpreadsheet Spreadsheet object,
206
     *                                    or NULL to create a standalone calculation engine
207
     */
208
    public static function getInstance(?Spreadsheet $spreadsheet = null): self
13,562✔
209
    {
210
        if ($spreadsheet !== null) {
13,562✔
211
            return $spreadsheet->getCalculationEngine();
9,749✔
212
        }
213

214
        if (!self::$instance) {
4,698✔
215
            self::$instance = new self();
15✔
216
        }
217

218
        return self::$instance;
4,698✔
219
    }
220

221
    /**
222
     * Intended for use only via a destructor.
223
     *
224
     * @internal
225
     */
226
    public static function getInstanceOrNull(?Spreadsheet $spreadsheet = null): ?self
138✔
227
    {
228
        if ($spreadsheet !== null) {
138✔
229
            return $spreadsheet->getCalculationEngineOrNull();
89✔
230
        }
231

232
        return null;
133✔
233
    }
234

235
    /**
236
     * Flush the calculation cache for any existing instance of this class
237
     *        but only if a Calculation instance exists.
238
     */
239
    public function flushInstance(): void
209✔
240
    {
241
        $this->clearCalculationCache();
209✔
242
        $this->branchPruner->clearBranchStore();
209✔
243
    }
244

245
    /**
246
     * Get the Logger for this calculation engine instance.
247
     */
248
    public function getDebugLog(): Logger
1,212✔
249
    {
250
        return $this->debugLog;
1,212✔
251
    }
252

253
    /**
254
     * __clone implementation. Cloning should not be allowed in a Singleton!
255
     */
256
    final public function __clone()
1✔
257
    {
258
        throw new Exception('Cloning the calculation engine is not allowed!');
1✔
259
    }
260

261
    /**
262
     * Set the Array Return Type (Array or Value of first element in the array).
263
     *
264
     * @param string $returnType Array return type
265
     *
266
     * @return bool Success or failure
267
     */
268
    public static function setArrayReturnType(string $returnType): bool
588✔
269
    {
270
        if (
271
            ($returnType == self::RETURN_ARRAY_AS_VALUE)
588✔
272
            || ($returnType == self::RETURN_ARRAY_AS_ERROR)
588✔
273
            || ($returnType == self::RETURN_ARRAY_AS_ARRAY)
588✔
274
        ) {
275
            self::$returnArrayAsType = $returnType;
588✔
276

277
            return true;
588✔
278
        }
279

280
        return false;
1✔
281
    }
282

283
    /**
284
     * Return the Array Return Type (Array or Value of first element in the array).
285
     *
286
     * @return string $returnType Array return type
287
     */
288
    public static function getArrayReturnType(): string
588✔
289
    {
290
        return self::$returnArrayAsType;
588✔
291
    }
292

293
    /**
294
     * Set the Instance Array Return Type (Array or Value of first element in the array).
295
     *
296
     * @param string $returnType Array return type
297
     *
298
     * @return bool Success or failure
299
     */
300
    public function setInstanceArrayReturnType(string $returnType): bool
425✔
301
    {
302
        if (
303
            ($returnType == self::RETURN_ARRAY_AS_VALUE)
425✔
304
            || ($returnType == self::RETURN_ARRAY_AS_ERROR)
425✔
305
            || ($returnType == self::RETURN_ARRAY_AS_ARRAY)
425✔
306
        ) {
307
            $this->instanceArrayReturnType = $returnType;
423✔
308

309
            return true;
423✔
310
        }
311

312
        return false;
2✔
313
    }
314

315
    /**
316
     * Return the Array Return Type (Array or Value of first element in the array).
317
     *
318
     * @return string $returnType Array return type for instance if non-null, otherwise static property
319
     */
320
    public function getInstanceArrayReturnType(): string
9,279✔
321
    {
322
        return $this->instanceArrayReturnType ?? self::$returnArrayAsType;
9,279✔
323
    }
324

325
    /**
326
     * Is calculation caching enabled?
327
     */
328
    public function getCalculationCacheEnabled(): bool
3,803✔
329
    {
330
        return $this->calculationCacheEnabled;
3,803✔
331
    }
332

333
    /**
334
     * Enable/disable calculation cache.
335
     */
336
    public function setCalculationCacheEnabled(bool $calculationCacheEnabled): self
10✔
337
    {
338
        $this->calculationCacheEnabled = $calculationCacheEnabled;
10✔
339
        $this->clearCalculationCache();
10✔
340

341
        return $this;
10✔
342
    }
343

344
    /**
345
     * Enable calculation cache.
346
     */
347
    public function enableCalculationCache(): void
×
348
    {
349
        $this->setCalculationCacheEnabled(true);
×
350
    }
351

352
    /**
353
     * Disable calculation cache.
354
     */
355
    public function disableCalculationCache(): void
×
356
    {
357
        $this->setCalculationCacheEnabled(false);
×
358
    }
359

360
    /**
361
     * Clear calculation cache.
362
     */
363
    public function clearCalculationCache(): void
221✔
364
    {
365
        $this->calculationCache = [];
221✔
366
    }
367

368
    /**
369
     * Clear calculation cache for a specified worksheet.
370
     */
371
    public function clearCalculationCacheForWorksheet(string $worksheetName): void
88✔
372
    {
373
        if (isset($this->calculationCache[$worksheetName])) {
88✔
374
            unset($this->calculationCache[$worksheetName]);
×
375
        }
376
    }
377

378
    /**
379
     * Rename calculation cache for a specified worksheet.
380
     */
381
    public function renameCalculationCacheForWorksheet(string $fromWorksheetName, string $toWorksheetName): void
1,473✔
382
    {
383
        if (isset($this->calculationCache[$fromWorksheetName])) {
1,473✔
384
            $this->calculationCache[$toWorksheetName] = &$this->calculationCache[$fromWorksheetName];
×
385
            unset($this->calculationCache[$fromWorksheetName]);
×
386
        }
387
    }
388

389
    public function getBranchPruningEnabled(): bool
10✔
390
    {
391
        return $this->branchPruningEnabled;
10✔
392
    }
393

394
    public function setBranchPruningEnabled(mixed $enabled): self
8,403✔
395
    {
396
        $this->branchPruningEnabled = (bool) $enabled;
8,403✔
397
        $this->branchPruner = new BranchPruner($this->branchPruningEnabled);
8,403✔
398

399
        return $this;
8,403✔
400
    }
401

402
    public function enableBranchPruning(): void
×
403
    {
404
        $this->setBranchPruningEnabled(true);
×
405
    }
406

407
    public function disableBranchPruning(): void
8,393✔
408
    {
409
        $this->setBranchPruningEnabled(false);
8,393✔
410
    }
411

412
    /**
413
     * Wrap string values in quotes.
414
     */
415
    public static function wrapResult(mixed $value): mixed
11,894✔
416
    {
417
        if (is_string($value)) {
11,894✔
418
            //    Error values cannot be "wrapped"
419
            if (Preg::isMatch('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
4,077✔
420
                //    Return Excel errors "as is"
421
                return $value;
1,308✔
422
            }
423

424
            //    Return strings wrapped in quotes
425
            return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
3,252✔
426
        } elseif ((is_float($value)) && ((is_nan($value)) || (is_infinite($value)))) {
9,473✔
427
            //    Convert numeric errors to NaN error
428
            return ExcelError::NAN();
4✔
429
        }
430

431
        return $value;
9,470✔
432
    }
433

434
    /**
435
     * Remove quotes used as a wrapper to identify string values.
436
     */
437
    public static function unwrapResult(mixed $value): mixed
12,129✔
438
    {
439
        if (is_string($value)) {
12,129✔
440
            if ((isset($value[0])) && ($value[0] == self::FORMULA_STRING_QUOTE) && (substr($value, -1) == self::FORMULA_STRING_QUOTE)) {
4,165✔
441
                return substr($value, 1, -1);
3,243✔
442
            }
443
            //    Convert numeric errors to NAN error
444
        } elseif ((is_float($value)) && ((is_nan($value)) || (is_infinite($value)))) {
11,283✔
445
            return ExcelError::NAN();
×
446
        }
447

448
        return $value;
11,508✔
449
    }
450

451
    /**
452
     * Calculate cell value (using formula from a cell ID)
453
     * Retained for backward compatibility.
454
     *
455
     * @param ?Cell $cell Cell to calculate
456
     */
457
    public function calculate(?Cell $cell = null): mixed
2✔
458
    {
459
        try {
460
            return $this->calculateCellValue($cell);
2✔
461
        } catch (\Exception $e) {
1✔
462
            throw new Exception($e->getMessage());
1✔
463
        }
464
    }
465

466
    /**
467
     * Calculate the value of a cell formula.
468
     *
469
     * @param ?Cell $cell Cell to calculate
470
     * @param bool $resetLog Flag indicating whether the debug log should be reset or not
471
     */
472
    public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): mixed
8,467✔
473
    {
474
        if ($cell === null) {
8,467✔
475
            return null;
×
476
        }
477

478
        if ($resetLog) {
8,467✔
479
            //    Initialise the logging settings if requested
480
            $this->formulaError = null;
8,455✔
481
            $this->debugLog->clearLog();
8,455✔
482
            $this->cyclicReferenceStack->clear();
8,455✔
483
            $this->cyclicFormulaCounter = 1;
8,455✔
484
        }
485

486
        //    Execute the calculation for the cell formula
487
        $this->cellStack[] = [
8,467✔
488
            'sheet' => $cell->getWorksheet()->getTitle(),
8,467✔
489
            'cell' => $cell->getCoordinate(),
8,467✔
490
        ];
8,467✔
491

492
        $cellAddressAttempted = false;
8,467✔
493
        $cellAddress = null;
8,467✔
494

495
        try {
496
            $value = $cell->getValue();
8,467✔
497
            if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
8,467✔
498
                $value = Preg::replaceCallback(
8,467✔
499
                    self::CALCULATION_REGEXP_CELLREF_SPILL,
8,467✔
500
                    fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
8,467✔
501
                    $value
8,467✔
502
                );
8,467✔
503
            }
504
            $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line
8,467✔
505
            if ($this->spreadsheet === null) {
8,233✔
506
                throw new Exception('null spreadsheet in calculateCellValue');
×
507
            }
508
            $cellAddressAttempted = true;
8,233✔
509
            $cellAddress = array_pop($this->cellStack);
8,233✔
510
            if ($cellAddress === null) {
8,233✔
511
                throw new Exception('null cellAddress in calculateCellValue');
×
512
            }
513
            /** @var array{sheet: string, cell: string} $cellAddress */
514
            $testSheet = $this->spreadsheet->getSheetByName($cellAddress['sheet']);
8,233✔
515
            if ($testSheet === null) {
8,233✔
516
                throw new Exception('worksheet not found in calculateCellValue');
×
517
            }
518
            $testSheet->getCell($cellAddress['cell']);
8,233✔
519
        } catch (\Exception $e) {
244✔
520
            if (!$cellAddressAttempted) {
244✔
521
                $cellAddress = array_pop($this->cellStack);
244✔
522
            }
523
            if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
244✔
524
                $sheetName = $cellAddress['sheet'] ?? null;
244✔
525
                $testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
244✔
526
                if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
244✔
527
                    /** @var array{cell: string} $cellAddress */
528
                    $testSheet->getCell($cellAddress['cell']);
244✔
529
                }
530
            }
531

532
            throw new Exception($e->getMessage(), $e->getCode(), $e);
244✔
533
        }
534

535
        if (is_array($result) && $this->getInstanceArrayReturnType() !== self::RETURN_ARRAY_AS_ARRAY) {
8,233✔
536
            $testResult = Functions::flattenArray($result);
5,066✔
537
            if ($this->getInstanceArrayReturnType() == self::RETURN_ARRAY_AS_ERROR) {
5,066✔
538
                return ExcelError::VALUE();
1✔
539
            }
540
            $result = array_shift($testResult);
5,065✔
541
        }
542

543
        if ($result === null && $cell->getWorksheet()->getSheetView()->getShowZeros()) {
8,232✔
544
            return 0;
18✔
545
        } elseif ((is_float($result)) && ((is_nan($result)) || (is_infinite($result)))) {
8,230✔
546
            return ExcelError::NAN();
×
547
        }
548

549
        return $result;
8,230✔
550
    }
551

552
    /**
553
     * Validate and parse a formula string.
554
     *
555
     * @param string $formula Formula to parse
556
     *
557
     * @return array<mixed>|bool
558
     */
559
    public function parseFormula(string $formula): array|bool
8,438✔
560
    {
561
        $formula = Preg::replaceCallback(
8,438✔
562
            self::CALCULATION_REGEXP_CELLREF_SPILL,
8,438✔
563
            fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
8,438✔
564
            $formula
8,438✔
565
        );
8,438✔
566
        //    Basic validation that this is indeed a formula
567
        //    We return an empty array if not
568
        $formula = trim($formula);
8,438✔
569
        if ((!isset($formula[0])) || ($formula[0] != '=')) {
8,438✔
570
            return [];
1✔
571
        }
572
        $formula = ltrim(substr($formula, 1));
8,438✔
573
        if (!isset($formula[0])) {
8,438✔
574
            return [];
1✔
575
        }
576

577
        //    Parse the formula and return the token stack
578
        return $this->internalParseFormula($formula);
8,437✔
579
    }
580

581
    /**
582
     * Calculate the value of a formula.
583
     *
584
     * @param string $formula Formula to parse
585
     * @param ?string $cellID Address of the cell to calculate
586
     * @param ?Cell $cell Cell to calculate
587
     */
588
    public function calculateFormula(string $formula, ?string $cellID = null, ?Cell $cell = null): mixed
3,793✔
589
    {
590
        //    Initialise the logging settings
591
        $this->formulaError = null;
3,793✔
592
        $this->debugLog->clearLog();
3,793✔
593
        $this->cyclicReferenceStack->clear();
3,793✔
594

595
        $resetCache = $this->getCalculationCacheEnabled();
3,793✔
596
        if ($this->spreadsheet !== null && $cellID === null && $cell === null) {
3,793✔
597
            $cellID = 'A1';
174✔
598
            $cell = $this->spreadsheet->getActiveSheet()->getCell($cellID);
174✔
599
        } else {
600
            //    Disable calculation cacheing because it only applies to cell calculations, not straight formulae
601
            //    But don't actually flush any cache
602
            $this->calculationCacheEnabled = false;
3,619✔
603
        }
604

605
        //    Execute the calculation
606
        try {
607
            $result = self::unwrapResult($this->_calculateFormulaValue($formula, $cellID, $cell));
3,793✔
608
        } catch (\Exception $e) {
3✔
609
            throw new Exception($e->getMessage());
3✔
610
        }
611

612
        if ($this->spreadsheet === null) {
3,790✔
613
            //    Reset calculation cacheing to its previous state
614
            $this->calculationCacheEnabled = $resetCache;
3,603✔
615
        }
616

617
        return $result;
3,790✔
618
    }
619

620
    public function getValueFromCache(string $cellReference, mixed &$cellValue): bool
8,620✔
621
    {
622
        $this->debugLog->writeDebugLog('Testing cache value for cell %s', $cellReference);
8,620✔
623
        // Is calculation cacheing enabled?
624
        // If so, is the required value present in calculation cache?
625
        if (($this->calculationCacheEnabled) && (isset($this->calculationCache[$cellReference]))) {
8,620✔
626
            $this->debugLog->writeDebugLog('Retrieving value for cell %s from cache', $cellReference);
360✔
627
            // Return the cached result
628

629
            $cellValue = $this->calculationCache[$cellReference];
360✔
630

631
            return true;
360✔
632
        }
633

634
        return false;
8,620✔
635
    }
636

637
    public function saveValueToCache(string $cellReference, mixed $cellValue): void
8,385✔
638
    {
639
        if ($this->calculationCacheEnabled) {
8,385✔
640
            $this->calculationCache[$cellReference] = $cellValue;
8,375✔
641
        }
642
    }
643

644
    /**
645
     * Parse a cell formula and calculate its value.
646
     *
647
     * @param string $formula The formula to parse and calculate
648
     * @param ?string $cellID The ID (e.g. A3) of the cell that we are calculating
649
     * @param ?Cell $cell Cell to calculate
650
     * @param bool $ignoreQuotePrefix If set to true, evaluate the formyla even if the referenced cell is quote prefixed
651
     */
652
    public function _calculateFormulaValue(string $formula, ?string $cellID = null, ?Cell $cell = null, bool $ignoreQuotePrefix = false): mixed
12,353✔
653
    {
654
        $cellValue = null;
12,353✔
655

656
        //  Quote-Prefixed cell values cannot be formulae, but are treated as strings
657
        if ($cell !== null && $ignoreQuotePrefix === false && $cell->getStyle()->getQuotePrefix() === true) {
12,353✔
658
            return self::wrapResult((string) $formula);
1✔
659
        }
660

661
        // https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
662
        if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
12,353✔
663
            return ExcelError::REF(); // returns #BLOCKED in newer versions
1✔
664
        }
665

666
        //    Basic validation that this is indeed a formula
667
        //    We simply return the cell value if not
668
        $formula = trim($formula);
12,352✔
669
        if ($formula === '' || $formula[0] !== '=') {
12,352✔
670
            return self::wrapResult($formula);
2✔
671
        }
672
        $formula = ltrim(substr($formula, 1));
12,352✔
673
        if (!isset($formula[0])) {
12,352✔
674
            return self::wrapResult($formula);
6✔
675
        }
676

677
        $pCellParent = ($cell !== null) ? $cell->getWorksheet() : null;
12,351✔
678
        $wsTitle = ($pCellParent !== null) ? $pCellParent->getTitle() : "\x00Wrk";
12,351✔
679
        $wsCellReference = $wsTitle . '!' . $cellID;
12,351✔
680

681
        if (($cellID !== null) && ($this->getValueFromCache($wsCellReference, $cellValue))) {
12,351✔
682
            return $cellValue;
356✔
683
        }
684
        $this->debugLog->writeDebugLog('Evaluating formula for cell %s', $wsCellReference);
12,351✔
685

686
        if (($wsTitle[0] !== "\x00") && ($this->cyclicReferenceStack->onStack($wsCellReference))) {
12,351✔
687
            if ($this->cyclicFormulaCount <= 0) {
13✔
688
                $this->cyclicFormulaCell = '';
1✔
689

690
                return $this->raiseFormulaError('Cyclic Reference in Formula');
1✔
691
            } elseif ($this->cyclicFormulaCell === $wsCellReference) {
12✔
692
                ++$this->cyclicFormulaCounter;
1✔
693
                if ($this->cyclicFormulaCounter >= $this->cyclicFormulaCount) {
1✔
694
                    $this->cyclicFormulaCell = '';
1✔
695

696
                    return $cellValue;
1✔
697
                }
698
            } elseif ($this->cyclicFormulaCell == '') {
12✔
699
                if ($this->cyclicFormulaCounter >= $this->cyclicFormulaCount) {
12✔
700
                    return $cellValue;
11✔
701
                }
702
                $this->cyclicFormulaCell = $wsCellReference;
1✔
703
            }
704
        }
705

706
        $this->debugLog->writeDebugLog('Formula for cell %s is %s', $wsCellReference, $formula);
12,351✔
707
        //    Parse the formula onto the token stack and calculate the value
708
        $this->cyclicReferenceStack->push($wsCellReference);
12,351✔
709

710
        $cellValue = $this->processTokenStack($this->internalParseFormula($formula, $cell), $cellID, $cell);
12,351✔
711
        $this->cyclicReferenceStack->pop();
12,114✔
712

713
        // Save to calculation cache
714
        if ($cellID !== null) {
12,114✔
715
            $this->saveValueToCache($wsCellReference, $cellValue);
8,385✔
716
        }
717

718
        //    Return the calculated value
719
        return $cellValue;
12,114✔
720
    }
721

722
    /**
723
     * Ensure that paired matrix operands are both matrices and of the same size.
724
     *
725
     * @param mixed $operand1 First matrix operand
726
     *
727
     * @param-out mixed[] $operand1
728
     *
729
     * @param mixed $operand2 Second matrix operand
730
     *
731
     * @param-out mixed[] $operand2
732
     *
733
     * @param int $resize Flag indicating whether the matrices should be resized to match
734
     *                                        and (if so), whether the smaller dimension should grow or the
735
     *                                        larger should shrink.
736
     *                                            0 = no resize
737
     *                                            1 = shrink to fit
738
     *                                            2 = extend to fit
739
     *
740
     * @return mixed[]
741
     */
742
    public static function checkMatrixOperands(mixed &$operand1, mixed &$operand2, int $resize = 1): array
69✔
743
    {
744
        //    Examine each of the two operands, and turn them into an array if they aren't one already
745
        //    Note that this function should only be called if one or both of the operand is already an array
746
        if (!is_array($operand1)) {
69✔
747
            if (is_array($operand2)) {
20✔
748
                [$matrixRows, $matrixColumns] = self::getMatrixDimensions($operand2);
20✔
749
                $operand1 = array_fill(0, $matrixRows, array_fill(0, $matrixColumns, $operand1));
20✔
750
                $resize = 0;
20✔
751
            } else {
752
                $operand1 = [$operand1];
×
753
                $operand2 = [$operand2];
×
754
            }
755
        } elseif (!is_array($operand2)) {
56✔
756
            [$matrixRows, $matrixColumns] = self::getMatrixDimensions($operand1);
18✔
757
            $operand2 = array_fill(0, $matrixRows, array_fill(0, $matrixColumns, $operand2));
18✔
758
            $resize = 0;
18✔
759
        }
760

761
        [$matrix1Rows, $matrix1Columns] = self::getMatrixDimensions($operand1);
69✔
762
        [$matrix2Rows, $matrix2Columns] = self::getMatrixDimensions($operand2);
69✔
763
        if ($resize === 3) {
69✔
764
            $resize = 2;
25✔
765
        } elseif (($matrix1Rows == $matrix2Columns) && ($matrix2Rows == $matrix1Columns)) {
47✔
766
            $resize = 1;
38✔
767
        }
768

769
        if ($resize == 2) {
69✔
770
            //    Given two matrices of (potentially) unequal size, convert the smaller in each dimension to match the larger
771
            self::resizeMatricesExtend($operand1, $operand2, $matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns);
27✔
772
        } elseif ($resize == 1) {
46✔
773
            //    Given two matrices of (potentially) unequal size, convert the larger in each dimension to match the smaller
774
            /** @var mixed[][] $operand1 */
775
            /** @var mixed[][] $operand2 */
776
            self::resizeMatricesShrink($operand1, $operand2, $matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns);
38✔
777
        }
778
        [$matrix1Rows, $matrix1Columns] = self::getMatrixDimensions($operand1);
69✔
779
        [$matrix2Rows, $matrix2Columns] = self::getMatrixDimensions($operand2);
69✔
780

781
        return [$matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns];
69✔
782
    }
783

784
    /**
785
     * Read the dimensions of a matrix, and re-index it with straight numeric keys starting from row 0, column 0.
786
     *
787
     * @param mixed[] $matrix matrix operand
788
     *
789
     * @return int[] An array comprising the number of rows, and number of columns
790
     */
791
    public static function getMatrixDimensions(array &$matrix): array
111✔
792
    {
793
        $matrixRows = count($matrix);
111✔
794
        $matrixColumns = 0;
111✔
795
        foreach ($matrix as $rowKey => $rowValue) {
111✔
796
            if (!is_array($rowValue)) {
109✔
797
                $matrix[$rowKey] = [$rowValue];
4✔
798
                $matrixColumns = max(1, $matrixColumns);
4✔
799
            } else {
800
                $matrix[$rowKey] = array_values($rowValue);
105✔
801
                $matrixColumns = max(count($rowValue), $matrixColumns);
105✔
802
            }
803
        }
804
        $matrix = array_values($matrix);
111✔
805

806
        return [$matrixRows, $matrixColumns];
111✔
807
    }
808

809
    /**
810
     * Ensure that paired matrix operands are both matrices of the same size.
811
     *
812
     * @param mixed[][] $matrix1 First matrix operand
813
     * @param mixed[][] $matrix2 Second matrix operand
814
     * @param int $matrix1Rows Row size of first matrix operand
815
     * @param int $matrix1Columns Column size of first matrix operand
816
     * @param int $matrix2Rows Row size of second matrix operand
817
     * @param int $matrix2Columns Column size of second matrix operand
818
     */
819
    private static function resizeMatricesShrink(array &$matrix1, array &$matrix2, int $matrix1Rows, int $matrix1Columns, int $matrix2Rows, int $matrix2Columns): void
40✔
820
    {
821
        if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) {
40✔
822
            if ($matrix2Rows < $matrix1Rows) {
1✔
823
                for ($i = $matrix2Rows; $i < $matrix1Rows; ++$i) {
1✔
824
                    unset($matrix1[$i]);
1✔
825
                }
826
            }
827
            if ($matrix2Columns < $matrix1Columns) {
1✔
828
                for ($i = 0; $i < $matrix1Rows; ++$i) {
1✔
829
                    for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) {
1✔
830
                        unset($matrix1[$i][$j]);
1✔
831
                    }
832
                }
833
            }
834
        }
835

836
        if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) {
40✔
837
            if ($matrix1Rows < $matrix2Rows) {
1✔
838
                for ($i = $matrix1Rows; $i < $matrix2Rows; ++$i) {
1✔
839
                    unset($matrix2[$i]);
1✔
840
                }
841
            }
842
            if ($matrix1Columns < $matrix2Columns) {
1✔
843
                for ($i = 0; $i < $matrix2Rows; ++$i) {
1✔
844
                    for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) {
1✔
845
                        unset($matrix2[$i][$j]);
1✔
846
                    }
847
                }
848
            }
849
        }
850
    }
851

852
    /**
853
     * Ensure that paired matrix operands are both matrices of the same size.
854
     *
855
     * @param mixed[] $matrix1 First matrix operand
856
     * @param mixed[] $matrix2 Second matrix operand
857
     * @param int $matrix1Rows Row size of first matrix operand
858
     * @param int $matrix1Columns Column size of first matrix operand
859
     * @param int $matrix2Rows Row size of second matrix operand
860
     * @param int $matrix2Columns Column size of second matrix operand
861
     */
862
    private static function resizeMatricesExtend(array &$matrix1, array &$matrix2, int $matrix1Rows, int $matrix1Columns, int $matrix2Rows, int $matrix2Columns): void
29✔
863
    {
864
        if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) {
29✔
865
            if ($matrix2Columns < $matrix1Columns) {
17✔
866
                for ($i = 0; $i < $matrix2Rows; ++$i) {
15✔
867
                    /** @var mixed[][] $matrix2 */
868
                    $x = ($matrix2Columns === 1) ? $matrix2[$i][0] : null;
15✔
869
                    for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) {
15✔
870
                        $matrix2[$i][$j] = $x;
15✔
871
                    }
872
                }
873
            }
874
            if ($matrix2Rows < $matrix1Rows) {
17✔
875
                $x = ($matrix2Rows === 1) ? $matrix2[0] : array_fill(0, $matrix2Columns, null);
3✔
876
                for ($i = $matrix2Rows; $i < $matrix1Rows; ++$i) {
3✔
877
                    $matrix2[$i] = $x;
3✔
878
                }
879
            }
880
        }
881

882
        if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) {
29✔
883
            if ($matrix1Columns < $matrix2Columns) {
18✔
884
                for ($i = 0; $i < $matrix1Rows; ++$i) {
1✔
885
                    /** @var mixed[][] $matrix1 */
886
                    $x = ($matrix1Columns === 1) ? $matrix1[$i][0] : null;
1✔
887
                    for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) {
1✔
888
                        $matrix1[$i][$j] = $x;
1✔
889
                    }
890
                }
891
            }
892
            if ($matrix1Rows < $matrix2Rows) {
18✔
893
                $x = ($matrix1Rows === 1) ? $matrix1[0] : array_fill(0, $matrix2Columns, null);
18✔
894
                for ($i = $matrix1Rows; $i < $matrix2Rows; ++$i) {
18✔
895
                    $matrix1[$i] = $x;
18✔
896
                }
897
            }
898
        }
899
    }
900

901
    /**
902
     * Format details of an operand for display in the log (based on operand type).
903
     *
904
     * @param mixed $value First matrix operand
905
     */
906
    private function showValue(mixed $value): mixed
12,091✔
907
    {
908
        if ($this->debugLog->getWriteDebugLog()) {
12,091✔
909
            $testArray = Functions::flattenArray($value);
3✔
910
            if (count($testArray) == 1) {
3✔
911
                $value = array_pop($testArray);
3✔
912
            }
913

914
            if (is_array($value)) {
3✔
915
                $returnMatrix = [];
2✔
916
                $pad = $rpad = ', ';
2✔
917
                foreach ($value as $row) {
2✔
918
                    if (is_array($row)) {
2✔
919
                        $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row));
2✔
920
                        $rpad = '; ';
2✔
921
                    } else {
922
                        $returnMatrix[] = $this->showValue($row);
×
923
                    }
924
                }
925

926
                return '{ ' . implode($rpad, $returnMatrix) . ' }';
2✔
927
            } elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
3✔
928
                return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
2✔
929
            } elseif (is_bool($value)) {
3✔
930
                return ($value) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
×
931
            } elseif ($value === null) {
3✔
932
                return self::$localeBoolean['NULL'];
×
933
            }
934
        }
935

936
        return Functions::flattenSingleValue($value);
12,091✔
937
    }
938

939
    /**
940
     * Format type and details of an operand for display in the log (based on operand type).
941
     *
942
     * @param mixed $value First matrix operand
943
     */
944
    private function showTypeDetails(mixed $value): ?string
12,109✔
945
    {
946
        if ($this->debugLog->getWriteDebugLog()) {
12,109✔
947
            $testArray = Functions::flattenArray($value);
3✔
948
            if (count($testArray) == 1) {
3✔
949
                $value = array_pop($testArray);
3✔
950
            }
951

952
            if ($value === null) {
3✔
953
                return 'a NULL value';
×
954
            } elseif (is_float($value)) {
3✔
955
                $typeString = 'a floating point number';
3✔
956
            } elseif (is_int($value)) {
3✔
957
                $typeString = 'an integer number';
3✔
958
            } elseif (is_bool($value)) {
2✔
959
                $typeString = 'a boolean';
×
960
            } elseif (is_array($value)) {
2✔
961
                $typeString = 'a matrix';
2✔
962
            } else {
963
                /** @var string $value */
964
                if ($value == '') {
×
965
                    return 'an empty string';
×
966
                } elseif ($value[0] == '#') {
×
967
                    return 'a ' . $value . ' error';
×
968
                }
969
                $typeString = 'a string';
×
970
            }
971

972
            return $typeString . ' with a value of ' . StringHelper::convertToString($this->showValue($value));
3✔
973
        }
974

975
        return null;
12,106✔
976
    }
977

978
    private const MATRIX_REPLACE_FROM = [self::FORMULA_OPEN_MATRIX_BRACE, ';', self::FORMULA_CLOSE_MATRIX_BRACE];
979
    private const MATRIX_REPLACE_TO = ['MKMATRIX(MKMATRIX(', '),MKMATRIX(', '))'];
980

981
    /**
982
     * @return false|string False indicates an error
983
     */
984
    private function convertMatrixReferences(string $formula): false|string
12,493✔
985
    {
986
        //    Convert any Excel matrix references to the MKMATRIX() function
987
        if (str_contains($formula, self::FORMULA_OPEN_MATRIX_BRACE)) {
12,493✔
988
            //    If there is the possibility of braces within a quoted string, then we don't treat those as matrix indicators
989
            if (str_contains($formula, self::FORMULA_STRING_QUOTE)) {
825✔
990
                //    So instead we skip replacing in any quoted strings by only replacing in every other array element after we've exploded
991
                //        the formula
992
                $temp = explode(self::FORMULA_STRING_QUOTE, $formula);
252✔
993
                //    Open and Closed counts used for trapping mismatched braces in the formula
994
                $openCount = $closeCount = 0;
252✔
995
                $notWithinQuotes = false;
252✔
996
                foreach ($temp as &$value) {
252✔
997
                    //    Only count/replace in alternating array entries
998
                    $notWithinQuotes = $notWithinQuotes === false;
252✔
999
                    if ($notWithinQuotes === true) {
252✔
1000
                        $openCount += substr_count($value, self::FORMULA_OPEN_MATRIX_BRACE);
252✔
1001
                        $closeCount += substr_count($value, self::FORMULA_CLOSE_MATRIX_BRACE);
252✔
1002
                        $value = str_replace(self::MATRIX_REPLACE_FROM, self::MATRIX_REPLACE_TO, $value);
252✔
1003
                    }
1004
                }
1005
                unset($value);
252✔
1006
                //    Then rebuild the formula string
1007
                $formula = implode(self::FORMULA_STRING_QUOTE, $temp);
252✔
1008
            } else {
1009
                //    If there's no quoted strings, then we do a simple count/replace
1010
                $openCount = substr_count($formula, self::FORMULA_OPEN_MATRIX_BRACE);
575✔
1011
                $closeCount = substr_count($formula, self::FORMULA_CLOSE_MATRIX_BRACE);
575✔
1012
                $formula = str_replace(self::MATRIX_REPLACE_FROM, self::MATRIX_REPLACE_TO, $formula);
575✔
1013
            }
1014
            //    Trap for mismatched braces and trigger an appropriate error
1015
            if ($openCount < $closeCount) {
825✔
1016
                if ($openCount > 0) {
×
1017
                    return $this->raiseFormulaError("Formula Error: Mismatched matrix braces '}'");
×
1018
                }
1019

1020
                return $this->raiseFormulaError("Formula Error: Unexpected '}' encountered");
×
1021
            } elseif ($openCount > $closeCount) {
825✔
1022
                if ($closeCount > 0) {
1✔
1023
                    return $this->raiseFormulaError("Formula Error: Mismatched matrix braces '{'");
×
1024
                }
1025

1026
                return $this->raiseFormulaError("Formula Error: Unexpected '{' encountered");
1✔
1027
            }
1028
        }
1029

1030
        return $formula;
12,492✔
1031
    }
1032

1033
    /**
1034
     *    Comparison (Boolean) Operators.
1035
     *    These operators work on two values, but always return a boolean result.
1036
     */
1037
    private const COMPARISON_OPERATORS = ['>' => true, '<' => true, '=' => true, '>=' => true, '<=' => true, '<>' => true];
1038

1039
    /**
1040
     *    Operator Precedence.
1041
     *    This list includes all valid operators, whether binary (including boolean) or unary (such as %).
1042
     *    Array key is the operator, the value is its precedence.
1043
     */
1044
    private const OPERATOR_PRECEDENCE = [
1045
        ':' => 9, //    Range
1046
        '∩' => 8, //    Intersect
1047
        '∪' => 7, //    Union
1048
        '~' => 6, //    Negation
1049
        '%' => 5, //    Percentage
1050
        '^' => 4, //    Exponentiation
1051
        '*' => 3, '/' => 3, //    Multiplication and Division
1052
        '+' => 2, '-' => 2, //    Addition and Subtraction
1053
        '&' => 1, //    Concatenation
1054
        '>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, //    Comparison
1055
    ];
1056

1057
    /** @param string[] $matches */
1058
    private static function unionForComma(array $matches): string
4✔
1059
    {
1060
        return $matches[1] . str_replace(',', '∪', $matches[2]);
4✔
1061
    }
1062

1063
    private const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
1064
        . '([(]' // open paren
1065
        . self::CALCULATION_REGEXP_CELLREF // cell address
1066
        . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
1067
        . '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
1068
        . self::CALCULATION_REGEXP_CELLREF // cell address
1069
        . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
1070
        . ')+' // one or more occurrences
1071
        . '\s*[)])/i'; // optional whitespace, end paren
1072

1073
    /**
1074
     * @return array<int, mixed>|false
1075
     */
1076
    private function internalParseFormula(string $formula, ?Cell $cell = null): bool|array
12,493✔
1077
    {
1078
        if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
12,493✔
1079
            return false;
×
1080
        }
1081

1082
        $formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, self::unionForComma(...), $formula); // @phpstan-ignore-line
12,492✔
1083
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,492✔
1084

1085
        //    If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
1086
        //        so we store the parent worksheet so that we can re-attach it when necessary
1087
        $pCellParent = ($cell !== null) ? $cell->getWorksheet() : null;
12,492✔
1088

1089
        $regexpMatchString = '/^((?<string>' . self::CALCULATION_REGEXP_STRING
12,492✔
1090
                                . ')|(?<function>' . self::CALCULATION_REGEXP_FUNCTION
12,492✔
1091
                                . ')|(?<cellRef>' . self::CALCULATION_REGEXP_CELLREF
12,492✔
1092
                                . ')|(?<colRange>' . self::CALCULATION_REGEXP_COLUMN_RANGE
12,492✔
1093
                                . ')|(?<rowRange>' . self::CALCULATION_REGEXP_ROW_RANGE
12,492✔
1094
                                . ')|(?<number>' . self::CALCULATION_REGEXP_NUMBER
12,492✔
1095
                                . ')|(?<openBrace>' . self::CALCULATION_REGEXP_OPENBRACE
12,492✔
1096
                                . ')|(?<structuredReference>' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE
12,492✔
1097
                                . ')|(?<definedName>' . self::CALCULATION_REGEXP_DEFINEDNAME
12,492✔
1098
                                . ')|(?<error>' . self::CALCULATION_REGEXP_ERROR
12,492✔
1099
                                . '))/sui';
12,492✔
1100

1101
        //    Start with initialisation
1102
        $index = 0;
12,492✔
1103
        $stack = new Stack($this->branchPruner);
12,492✔
1104
        $output = [];
12,492✔
1105
        $expectingOperator = false; //    We use this test in syntax-checking the expression to determine when a
12,492✔
1106
        //        - is a negation or + is a positive operator rather than an operation
1107
        $expectingOperand = false; //    We use this test in syntax-checking the expression to determine whether an operand
12,492✔
1108
        //        should be null in a function call
1109

1110
        //    The guts of the lexical parser
1111
        //    Loop through the formula extracting each operator and operand in turn
1112
        while (true) {
12,492✔
1113
            // Branch pruning: we adapt the output item to the context (it will
1114
            // be used to limit its computation)
1115
            $this->branchPruner->initialiseForLoop();
12,492✔
1116

1117
            $opCharacter = $formula[$index]; //    Get the first character of the value at the current index position
12,492✔
1118
            if ($opCharacter === "\xe2") { // intersection or union
12,492✔
1119
                $opCharacter .= $formula[++$index];
7✔
1120
                $opCharacter .= $formula[++$index];
7✔
1121
            }
1122

1123
            // Check for two-character operators (e.g. >=, <=, <>)
1124
            if ((isset(self::COMPARISON_OPERATORS[$opCharacter])) && (strlen($formula) > $index) && isset($formula[$index + 1], self::COMPARISON_OPERATORS[$formula[$index + 1]])) {
12,492✔
1125
                $opCharacter .= $formula[++$index];
85✔
1126
            }
1127
            //    Find out if we're currently at the beginning of a number, variable, cell/row/column reference,
1128
            //         function, defined name, structured reference, parenthesis, error or operand
1129
            $isOperandOrFunction = (bool) preg_match($regexpMatchString, substr($formula, $index), $match);
12,492✔
1130

1131
            $expectingOperatorCopy = $expectingOperator;
12,492✔
1132
            if ($opCharacter === '-' && !$expectingOperator) {                //    Is it a negation instead of a minus?
12,492✔
1133
                //    Put a negation on the stack
1134
                $stack->push('Unary Operator', '~');
1,174✔
1135
                ++$index; //        and drop the negation symbol
1,174✔
1136
            } elseif ($opCharacter === '%' && $expectingOperator) {
12,492✔
1137
                //    Put a percentage on the stack
1138
                $stack->push('Unary Operator', '%');
11✔
1139
                ++$index;
11✔
1140
            } elseif ($opCharacter === '+' && !$expectingOperator) {            //    Positive (unary plus rather than binary operator plus) can be discarded?
12,492✔
1141
                ++$index; //    Drop the redundant plus symbol
7✔
1142
            } elseif ((($opCharacter === '~') /*|| ($opCharacter === '∩') || ($opCharacter === '∪')*/) && (!$isOperandOrFunction)) {
12,492✔
1143
                //    We have to explicitly deny a tilde, union or intersect because they are legal
UNCOV
1144
                return $this->raiseFormulaError("Formula Error: Illegal character '~'"); //        on the stack but not in the input expression
×
1145
            } elseif ((isset(self::CALCULATION_OPERATORS[$opCharacter]) || $isOperandOrFunction) && $expectingOperator) {    //    Are we putting an operator on the stack?
12,492✔
1146
                while (self::swapOperands($stack, $opCharacter)) {
1,897✔
1147
                    $output[] = $stack->pop(); //    Swap operands and higher precedence operators from the stack to the output
102✔
1148
                }
1149

1150
                //    Finally put our current operator onto the stack
1151
                $stack->push('Binary Operator', $opCharacter);
1,897✔
1152

1153
                ++$index;
1,897✔
1154
                $expectingOperator = false;
1,897✔
1155
            } elseif ($opCharacter === ')' && $expectingOperator) { //    Are we expecting to close a parenthesis?
12,492✔
1156
                $expectingOperand = false;
12,105✔
1157
                while (($o2 = $stack->pop()) && $o2['value'] !== '(') { //    Pop off the stack back to the last (
12,105✔
1158
                    $output[] = $o2;
1,433✔
1159
                }
1160
                $d = $stack->last(2);
12,105✔
1161

1162
                // Branch pruning we decrease the depth whether is it a function
1163
                // call or a parenthesis
1164
                $this->branchPruner->decrementDepth();
12,105✔
1165

1166
                if (is_array($d) && preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($d['value']), $matches)) {
12,105✔
1167
                    //    Did this parenthesis just close a function?
1168
                    try {
1169
                        $this->branchPruner->closingBrace($d['value']);
12,099✔
1170
                    } catch (Exception $e) {
4✔
1171
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
4✔
1172
                    }
1173

1174
                    $functionName = $matches[1]; //    Get the function name
12,099✔
1175
                    $d = $stack->pop();
12,099✔
1176
                    $argumentCount = $d['value'] ?? 0; //    See how many arguments there were (argument count is the next value stored on the stack)
12,099✔
1177
                    $output[] = $d; //    Dump the argument count on the output
12,099✔
1178
                    $output[] = $stack->pop(); //    Pop the function and push onto the output
12,099✔
1179
                    if (isset(self::$controlFunctions[$functionName])) {
12,099✔
1180
                        $expectedArgumentCount = self::$controlFunctions[$functionName]['argumentCount'];
845✔
1181
                    } elseif (isset($phpSpreadsheetFunctions[$functionName])) {
12,096✔
1182
                        $expectedArgumentCount = $phpSpreadsheetFunctions[$functionName]['argumentCount'];
12,096✔
1183
                    } else {    // did we somehow push a non-function on the stack? this should never happen
UNCOV
1184
                        return $this->raiseFormulaError('Formula Error: Internal error, non-function on stack');
×
1185
                    }
1186
                    //    Check the argument count
1187
                    $argumentCountError = false;
12,099✔
1188
                    $expectedArgumentCountString = null;
12,099✔
1189
                    if (is_numeric($expectedArgumentCount)) {
12,099✔
1190
                        if ($expectedArgumentCount < 0) {
6,191✔
1191
                            if ($argumentCount > abs($expectedArgumentCount + 0)) {
40✔
UNCOV
1192
                                $argumentCountError = true;
×
UNCOV
1193
                                $expectedArgumentCountString = 'no more than ' . abs($expectedArgumentCount + 0);
×
1194
                            }
1195
                        } else {
1196
                            if ($argumentCount != $expectedArgumentCount) {
6,154✔
1197
                                $argumentCountError = true;
146✔
1198
                                $expectedArgumentCountString = $expectedArgumentCount;
146✔
1199
                            }
1200
                        }
1201
                    } elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
6,684✔
1202
                        if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
6,195✔
1203
                            $argMatch = ['', '', '', ''];
1✔
1204
                        }
1205
                        switch ($argMatch[2]) {
6,195✔
1206
                            case '+':
6,195✔
1207
                                if ($argumentCount < $argMatch[1]) {
1,194✔
1208
                                    $argumentCountError = true;
27✔
1209
                                    $expectedArgumentCountString = $argMatch[1] . ' or more ';
27✔
1210
                                }
1211

1212
                                break;
1,194✔
1213
                            case '-':
5,169✔
1214
                                if (($argumentCount < $argMatch[1]) || ($argumentCount > $argMatch[3])) {
930✔
1215
                                    $argumentCountError = true;
15✔
1216
                                    $expectedArgumentCountString = 'between ' . $argMatch[1] . ' and ' . $argMatch[3];
15✔
1217
                                }
1218

1219
                                break;
930✔
1220
                            case ',':
4,281✔
1221
                                if (($argumentCount != $argMatch[1]) && ($argumentCount != $argMatch[3])) {
4,281✔
1222
                                    $argumentCountError = true;
39✔
1223
                                    $expectedArgumentCountString = 'either ' . $argMatch[1] . ' or ' . $argMatch[3];
39✔
1224
                                }
1225

1226
                                break;
4,281✔
1227
                        }
1228
                    }
1229
                    if ($argumentCountError) {
12,099✔
1230
                        /** @var int $argumentCount */
1231
                        return $this->raiseFormulaError("Formula Error: Wrong number of arguments for $functionName() function: $argumentCount given, " . $expectedArgumentCountString . ' expected');
227✔
1232
                    }
1233
                }
1234
                ++$index;
11,881✔
1235
            } elseif ($opCharacter === ',') { // Is this the separator for function arguments?
12,492✔
1236
                try {
1237
                    $this->branchPruner->argumentSeparator();
8,179✔
UNCOV
1238
                } catch (Exception $e) {
×
UNCOV
1239
                    return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1240
                }
1241

1242
                while (($o2 = $stack->pop()) && $o2['value'] !== '(') {        //    Pop off the stack back to the last (
8,179✔
1243
                    $output[] = $o2; // pop the argument expression stuff and push onto the output
1,512✔
1244
                }
1245
                //    If we've a comma when we're expecting an operand, then what we actually have is a null operand;
1246
                //        so push a null onto the stack
1247
                if (($expectingOperand) || (!$expectingOperator)) {
8,179✔
1248
                    $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
120✔
1249
                }
1250
                // make sure there was a function
1251
                $d = $stack->last(2);
8,179✔
1252
                /** @var string */
1253
                $temp = $d['value'] ?? '';
8,179✔
1254
                if (!preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $temp, $matches)) {
8,179✔
1255
                    // Can we inject a dummy function at this point so that the braces at least have some context
1256
                    //     because at least the braces are paired up (at this stage in the formula)
1257
                    // MS Excel allows this if the content is cell references; but doesn't allow actual values,
1258
                    //    but at this point, we can't differentiate (so allow both)
1259
                    //return $this->raiseFormulaError('Formula Error: Unexpected ,');
1260

1261
                    $stack->push('Binary Operator', '∪');
1✔
1262

1263
                    ++$index;
1✔
1264
                    $expectingOperator = false;
1✔
1265

1266
                    continue;
1✔
1267
                }
1268

1269
                /** @var array<string, int> $d */
1270
                $d = $stack->pop();
8,178✔
1271
                ++$d['value']; // increment the argument count
8,178✔
1272

1273
                $stack->pushStackItem($d);
8,178✔
1274
                $stack->push('Brace', '('); // put the ( back on, we'll need to pop back to it again
8,178✔
1275

1276
                $expectingOperator = false;
8,178✔
1277
                $expectingOperand = true;
8,178✔
1278
                ++$index;
8,178✔
1279
            } elseif ($opCharacter === '(' && !$expectingOperator) {
12,492✔
1280
                // Branch pruning: we go deeper
1281
                $this->branchPruner->incrementDepth();
41✔
1282
                $stack->push('Brace', '(', null);
41✔
1283
                ++$index;
41✔
1284
            } elseif ($isOperandOrFunction && !$expectingOperatorCopy) {
12,492✔
1285
                // do we now have a function/variable/number?
1286
                $expectingOperator = true;
12,488✔
1287
                $expectingOperand = false;
12,488✔
1288
                $val = $match[1] ?? ''; //* @phpstan-ignore-line
12,488✔
1289
                $length = strlen($val);
12,488✔
1290

1291
                if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
12,488✔
1292
                    // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
1293
                    $val = Preg::replace('/\s/u', '', $val);
12,106✔
1294
                    if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) {    // it's a function
12,106✔
1295
                        $valToUpper = strtoupper($val);
12,104✔
1296
                    } else {
1297
                        $valToUpper = 'NAME.ERROR(';
6✔
1298
                    }
1299
                    // here $matches[1] will contain values like "IF"
1300
                    // and $val "IF("
1301

1302
                    $this->branchPruner->functionCall($valToUpper);
12,106✔
1303

1304
                    $stack->push('Function', $valToUpper);
12,106✔
1305
                    // tests if the function is closed right after opening
1306
                    $ax = preg_match('/^\s*\)/u', substr($formula, $index + $length));
12,106✔
1307
                    if ($ax) {
12,106✔
1308
                        $stack->push('Operand Count for Function ' . $valToUpper . ')', 0);
328✔
1309
                        $expectingOperator = true;
328✔
1310
                    } else {
1311
                        $stack->push('Operand Count for Function ' . $valToUpper . ')', 1);
11,924✔
1312
                        $expectingOperator = false;
11,924✔
1313
                    }
1314
                    $stack->push('Brace', '(');
12,106✔
1315
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $val, $matches)) {
12,282✔
1316
                    //    Watch for this case-change when modifying to allow cell references in different worksheets...
1317
                    //    Should only be applied to the actual cell column, not the worksheet name
1318
                    //    If the last entry on the stack was a : operator, then we have a cell range reference
1319
                    $testPrevOp = $stack->last(1);
7,323✔
1320
                    if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
7,323✔
1321
                        //    If we have a worksheet reference, then we're playing with a 3D reference
1322
                        if ($matches[2] === '') {
1,307✔
1323
                            //    Otherwise, we 'inherit' the worksheet reference from the start cell reference
1324
                            //    The start of the cell range reference should be the last entry in $output
1325
                            $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
1,303✔
1326
                            if ($rangeStartCellRef === ':') {
1,303✔
1327
                                // Do we have chained range operators?
1328
                                $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
5✔
1329
                            }
1330
                            /** @var string $rangeStartCellRef */
1331
                            preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
1,303✔
1332
                            if (array_key_exists(2, $rangeStartMatches)) {
1,303✔
1333
                                if ($rangeStartMatches[2] > '') {
1,298✔
1334
                                    $val = $rangeStartMatches[2] . '!' . $val;
1,281✔
1335
                                }
1336
                            } else {
1337
                                $val = ExcelError::REF();
5✔
1338
                            }
1339
                        } else {
1340
                            $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
4✔
1341
                            if ($rangeStartCellRef === ':') {
4✔
1342
                                // Do we have chained range operators?
UNCOV
1343
                                $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
×
1344
                            }
1345
                            /** @var string $rangeStartCellRef */
1346
                            preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
4✔
1347
                            if (isset($rangeStartMatches[2]) && $rangeStartMatches[2] !== $matches[2]) {
4✔
1348
                                return $this->raiseFormulaError('3D Range references are not yet supported');
2✔
1349
                            }
1350
                        }
1351
                    } elseif (!str_contains($val, '!') && $pCellParent !== null) {
7,318✔
1352
                        $worksheet = $pCellParent->getTitle();
7,101✔
1353
                        $val = "'{$worksheet}'!{$val}";
7,101✔
1354
                    }
1355
                    // unescape any apostrophes or double quotes in worksheet name
1356
                    $val = str_replace(["''", '""'], ["'", '"'], $val);
7,323✔
1357
                    $outputItem = $stack->getStackItem('Cell Reference', $val, $val);
7,323✔
1358

1359
                    $output[] = $outputItem;
7,323✔
1360
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '$/miu', $val, $matches)) {
6,479✔
1361
                    try {
1362
                        $structuredReference = Operands\StructuredReference::fromParser($formula, $index, $matches);
76✔
UNCOV
1363
                    } catch (Exception $e) {
×
UNCOV
1364
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1365
                    }
1366

1367
                    $val = $structuredReference->value();
76✔
1368
                    $length = strlen($val);
76✔
1369
                    $outputItem = $stack->getStackItem(Operands\StructuredReference::NAME, $structuredReference, null);
76✔
1370

1371
                    $output[] = $outputItem;
76✔
1372
                    $expectingOperator = true;
76✔
1373
                } else {
1374
                    // it's a variable, constant, string, number or boolean
1375
                    $localeConstant = false;
6,412✔
1376
                    $stackItemType = 'Value';
6,412✔
1377
                    $stackItemReference = null;
6,412✔
1378

1379
                    //    If the last entry on the stack was a : operator, then we may have a row or column range reference
1380
                    $testPrevOp = $stack->last(1);
6,412✔
1381
                    if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
6,412✔
1382
                        $stackItemType = 'Cell Reference';
38✔
1383

1384
                        if (
1385
                            !is_numeric($val)
38✔
1386
                            && ((ctype_alpha($val) === false || strlen($val) > 3))
38✔
1387
                            && (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/mui', $val) !== false)
38✔
1388
                            && ($this->spreadsheet === null || $this->spreadsheet->getNamedRange($val) !== null)
38✔
1389
                        ) {
1390
                            $namedRange = ($this->spreadsheet === null) ? null : $this->spreadsheet->getNamedRange($val);
10✔
1391
                            if ($namedRange !== null) {
10✔
1392
                                $stackItemType = 'Defined Name';
4✔
1393
                                $address = str_replace('$', '', $namedRange->getValue());
4✔
1394
                                $stackItemReference = $val;
4✔
1395
                                if (str_contains($address, ':')) {
4✔
1396
                                    // We'll need to manipulate the stack for an actual named range rather than a named cell
1397
                                    $fromTo = explode(':', $address);
3✔
1398
                                    $to = array_pop($fromTo);
3✔
1399
                                    foreach ($fromTo as $from) {
3✔
1400
                                        $output[] = $stack->getStackItem($stackItemType, $from, $stackItemReference);
3✔
1401
                                        $output[] = $stack->getStackItem('Binary Operator', ':');
3✔
1402
                                    }
1403
                                    $address = $to;
3✔
1404
                                }
1405
                                $val = $address;
4✔
1406
                            }
1407
                        } elseif ($val === ExcelError::REF()) {
34✔
1408
                            $stackItemReference = $val;
3✔
1409
                        } else {
1410
                            /** @var non-empty-string $startRowColRef */
1411
                            $startRowColRef = $output[count($output) - 1]['value'] ?? '';
31✔
1412
                            [$rangeWS1, $startRowColRef] = Worksheet::extractSheetTitle($startRowColRef, true);
31✔
1413
                            $rangeSheetRef = $rangeWS1;
31✔
1414
                            if ($rangeWS1 !== '') {
31✔
1415
                                $rangeWS1 .= '!';
19✔
1416
                            }
1417
                            if (str_starts_with($rangeSheetRef, "'")) {
31✔
1418
                                $rangeSheetRef = Worksheet::unApostrophizeTitle($rangeSheetRef);
18✔
1419
                            }
1420
                            [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true);
31✔
1421
                            if ($rangeWS2 !== '') {
31✔
UNCOV
1422
                                $rangeWS2 .= '!';
×
1423
                            } else {
1424
                                $rangeWS2 = $rangeWS1;
31✔
1425
                            }
1426

1427
                            $refSheet = $pCellParent;
31✔
1428
                            if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) {
31✔
1429
                                $refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
4✔
1430
                            }
1431

1432
                            if (ctype_digit($val) && $val <= 1048576) {
31✔
1433
                                //    Row range
1434
                                $stackItemType = 'Row Reference';
10✔
1435
                                $valx = $val;
10✔
1436
                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataColumn($valx) : AddressRange::MAX_COLUMN; //    Max 16,384 columns for Excel2007
10✔
1437
                                $val = "{$rangeWS2}{$endRowColRef}{$val}";
10✔
1438
                            } elseif (ctype_alpha($val) && strlen($val) <= 3) {
21✔
1439
                                //    Column range
1440
                                $stackItemType = 'Column Reference';
15✔
1441
                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; //    Max 1,048,576 rows for Excel2007
15✔
1442
                                $val = "{$rangeWS2}{$val}{$endRowColRef}";
15✔
1443
                            }
1444
                            $stackItemReference = $val;
31✔
1445
                        }
1446
                    } elseif ($opCharacter === self::FORMULA_STRING_QUOTE) {
6,407✔
1447
                        //    UnEscape any quotes within the string
1448
                        $val = self::wrapResult(str_replace('""', self::FORMULA_STRING_QUOTE, StringHelper::convertToString(self::unwrapResult($val))));
2,864✔
1449
                    } elseif (isset(self::EXCEL_CONSTANTS[trim(strtoupper($val))])) {
4,760✔
1450
                        $stackItemType = 'Constant';
566✔
1451
                        $excelConstant = trim(strtoupper($val));
566✔
1452
                        $val = self::EXCEL_CONSTANTS[$excelConstant];
566✔
1453
                        $stackItemReference = $excelConstant;
566✔
1454
                    } elseif (($localeConstant = array_search(trim(strtoupper($val)), self::$localeBoolean)) !== false) {
4,468✔
1455
                        $stackItemType = 'Constant';
39✔
1456
                        $val = self::EXCEL_CONSTANTS[$localeConstant];
39✔
1457
                        $stackItemReference = $localeConstant;
39✔
1458
                    } elseif (
1459
                        preg_match('/^' . self::CALCULATION_REGEXP_ROW_RANGE . '/miu', substr($formula, $index), $rowRangeReference)
4,449✔
1460
                    ) {
1461
                        $val = $rowRangeReference[1];
10✔
1462
                        $length = strlen($rowRangeReference[1]);
10✔
1463
                        $stackItemType = 'Row Reference';
10✔
1464
                        // unescape any apostrophes or double quotes in worksheet name
1465
                        $val = str_replace(["''", '""'], ["'", '"'], $val);
10✔
1466
                        $column = 'A';
10✔
1467
                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
10✔
UNCOV
1468
                            $column = $pCellParent->getHighestDataColumn($val);
×
1469
                        }
1470
                        $val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
10✔
1471
                        $stackItemReference = $val;
10✔
1472
                    } elseif (
1473
                        preg_match('/^' . self::CALCULATION_REGEXP_COLUMN_RANGE . '/miu', substr($formula, $index), $columnRangeReference)
4,442✔
1474
                    ) {
1475
                        $val = $columnRangeReference[1];
15✔
1476
                        $length = strlen($val);
15✔
1477
                        $stackItemType = 'Column Reference';
15✔
1478
                        // unescape any apostrophes or double quotes in worksheet name
1479
                        $val = str_replace(["''", '""'], ["'", '"'], $val);
15✔
1480
                        $row = '1';
15✔
1481
                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
15✔
UNCOV
1482
                            $row = $pCellParent->getHighestDataRow($val);
×
1483
                        }
1484
                        $val = "{$val}{$row}";
15✔
1485
                        $stackItemReference = $val;
15✔
1486
                    } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', $val, $match)) {
4,427✔
1487
                        $stackItemType = 'Defined Name';
167✔
1488
                        $stackItemReference = $val;
167✔
1489
                    } elseif (is_numeric($val)) {
4,291✔
1490
                        if ((str_contains((string) $val, '.')) || (stripos((string) $val, 'e') !== false) || ($val > PHP_INT_MAX) || ($val < -PHP_INT_MAX)) {
4,285✔
1491
                            $val = (float) $val;
1,693✔
1492
                        } else {
1493
                            $val = (int) $val;
3,521✔
1494
                        }
1495
                    }
1496

1497
                    $details = $stack->getStackItem($stackItemType, $val, $stackItemReference);
6,412✔
1498
                    if ($localeConstant) {
6,412✔
1499
                        $details['localeValue'] = $localeConstant;
39✔
1500
                    }
1501
                    $output[] = $details;
6,412✔
1502
                }
1503
                $index += $length;
12,488✔
1504
            } elseif ($opCharacter === '$') { // absolute row or column range
96✔
1505
                ++$index;
6✔
1506
            } elseif ($opCharacter === ')') { // miscellaneous error checking
90✔
1507
                if ($expectingOperand) {
83✔
1508
                    $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
83✔
1509
                    $expectingOperand = false;
83✔
1510
                    $expectingOperator = true;
83✔
1511
                } else {
UNCOV
1512
                    return $this->raiseFormulaError("Formula Error: Unexpected ')'");
×
1513
                }
1514
            } elseif (isset(self::CALCULATION_OPERATORS[$opCharacter]) && !$expectingOperator) {
7✔
UNCOV
1515
                return $this->raiseFormulaError("Formula Error: Unexpected operator '$opCharacter'");
×
1516
            } else {    // I don't even want to know what you did to get here
1517
                return $this->raiseFormulaError('Formula Error: An unexpected error occurred');
7✔
1518
            }
1519
            //    Test for end of formula string
1520
            if ($index == strlen($formula)) {
12,488✔
1521
                //    Did we end with an operator?.
1522
                //    Only valid for the % unary operator
1523
                if ((isset(self::CALCULATION_OPERATORS[$opCharacter])) && ($opCharacter != '%')) {
12,259✔
1524
                    return $this->raiseFormulaError("Formula Error: Operator '$opCharacter' has no operands");
1✔
1525
                }
1526

1527
                break;
12,258✔
1528
            }
1529
            //    Ignore white space
1530
            while (($formula[$index] === "\n") || ($formula[$index] === "\r")) {
12,455✔
UNCOV
1531
                ++$index;
×
1532
            }
1533

1534
            if ($formula[$index] === ' ') {
12,455✔
1535
                while ($formula[$index] === ' ') {
2,129✔
1536
                    ++$index;
2,129✔
1537
                }
1538

1539
                //    If we're expecting an operator, but only have a space between the previous and next operands (and both are
1540
                //        Cell References, Defined Names or Structured References) then we have an INTERSECTION operator
1541
                $countOutputMinus1 = count($output) - 1;
2,129✔
1542
                if (
1543
                    ($expectingOperator)
2,129✔
1544
                    && array_key_exists($countOutputMinus1, $output)
2,129✔
1545
                    && is_array($output[$countOutputMinus1])
2,129✔
1546
                    && array_key_exists('type', $output[$countOutputMinus1])
2,129✔
1547
                    && (
1548
                        (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '.*/miu', substr($formula, $index), $match))
2,129✔
1549
                            && ($output[$countOutputMinus1]['type'] === 'Cell Reference')
2,129✔
1550
                        || (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', substr($formula, $index), $match))
2,129✔
1551
                            && ($output[$countOutputMinus1]['type'] === 'Defined Name' || $output[$countOutputMinus1]['type'] === 'Value')
2,129✔
1552
                        || (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '.*/miu', substr($formula, $index), $match))
2,129✔
1553
                            && ($output[$countOutputMinus1]['type'] === Operands\StructuredReference::NAME || $output[$countOutputMinus1]['type'] === 'Value')
2,129✔
1554
                    )
1555
                ) {
1556
                    while (self::swapOperands($stack, $opCharacter)) {
20✔
1557
                        $output[] = $stack->pop(); //    Swap operands and higher precedence operators from the stack to the output
13✔
1558
                    }
1559
                    $stack->push('Binary Operator', '∩'); //    Put an Intersect Operator on the stack
20✔
1560
                    $expectingOperator = false;
20✔
1561
                }
1562
            }
1563
        }
1564

1565
        while (($op = $stack->pop()) !== null) {
12,258✔
1566
            // pop everything off the stack and push onto output
1567
            if ($op['value'] == '(') {
758✔
1568
                return $this->raiseFormulaError("Formula Error: Expecting ')'"); // if there are any opening braces on the stack, then braces were unbalanced
5✔
1569
            }
1570
            $output[] = $op;
755✔
1571
        }
1572

1573
        return $output;
12,255✔
1574
    }
1575

1576
    /** @param mixed[] $operandData */
1577
    private static function dataTestReference(array &$operandData): mixed
1,781✔
1578
    {
1579
        $operand = $operandData['value'];
1,781✔
1580
        if (($operandData['reference'] === null) && (is_array($operand))) {
1,781✔
1581
            $rKeys = array_keys($operand);
46✔
1582
            $rowKey = array_shift($rKeys);
46✔
1583
            if (is_array($operand[$rowKey]) === false) {
46✔
1584
                $operandData['value'] = $operand[$rowKey];
6✔
1585

1586
                return $operand[$rowKey];
6✔
1587
            }
1588

1589
            $cKeys = array_keys(array_keys($operand[$rowKey]));
44✔
1590
            $colKey = array_shift($cKeys);
44✔
1591
            if (ctype_upper("$colKey")) {
44✔
UNCOV
1592
                $operandData['reference'] = $colKey . $rowKey;
×
1593
            }
1594
        }
1595

1596
        return $operand;
1,781✔
1597
    }
1598

1599
    private static int $matchIndex8 = 8;
1600

1601
    private static int $matchIndex9 = 9;
1602

1603
    private static int $matchIndex10 = 10;
1604

1605
    /**
1606
     * @param array<mixed>|false $tokens
1607
     *
1608
     * @return array<int, mixed>|false|string
1609
     */
1610
    private function processTokenStack(false|array $tokens, ?string $cellID = null, ?Cell $cell = null)
12,122✔
1611
    {
1612
        if ($tokens === false) {
12,122✔
1613
            return false;
2✔
1614
        }
1615
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,121✔
1616

1617
        //    If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent cell collection),
1618
        //        so we store the parent cell collection so that we can re-attach it when necessary
1619
        $pCellWorksheet = ($cell !== null) ? $cell->getWorksheet() : null;
12,121✔
1620
        $originalCoordinate = $cell?->getCoordinate();
12,121✔
1621
        $pCellParent = ($cell !== null) ? $cell->getParent() : null;
12,121✔
1622
        $stack = new Stack($this->branchPruner);
12,121✔
1623

1624
        // Stores branches that have been pruned
1625
        $fakedForBranchPruning = [];
12,121✔
1626
        // help us to know when pruning ['branchTestId' => true/false]
1627
        $branchStore = [];
12,121✔
1628
        //    Loop through each token in turn
1629
        foreach ($tokens as $tokenIdx => $tokenData) {
12,121✔
1630
            /** @var mixed[] $tokenData */
1631
            $this->processingAnchorArray = false;
12,121✔
1632
            if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line
12,121✔
1633
                $this->processingAnchorArray = true;
6✔
1634
            }
1635
            $token = $tokenData['value'];
12,121✔
1636
            // Branch pruning: skip useless resolutions
1637
            /** @var ?string */
1638
            $storeKey = $tokenData['storeKey'] ?? null;
12,121✔
1639
            if ($this->branchPruningEnabled && isset($tokenData['onlyIf'])) {
12,121✔
1640
                /** @var string */
1641
                $onlyIfStoreKey = $tokenData['onlyIf'];
84✔
1642
                $storeValue = $branchStore[$onlyIfStoreKey] ?? null;
84✔
1643
                $storeValueAsBool = ($storeValue === null)
84✔
1644
                    ? true : (bool) Functions::flattenSingleValue($storeValue);
84✔
1645
                if (is_array($storeValue)) {
84✔
1646
                    $wrappedItem = end($storeValue);
57✔
1647
                    $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
57✔
1648
                }
1649

1650
                if (
1651
                    (isset($storeValue) || $tokenData['reference'] === 'NULL')
84✔
1652
                    && (!$storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
84✔
1653
                ) {
1654
                    // If branching value is not true, we don't need to compute
1655
                    /** @var string $onlyIfStoreKey */
1656
                    if (!isset($fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey])) {
61✔
1657
                        /** @var string $token */
1658
                        $stack->push('Value', 'Pruned branch (only if ' . $onlyIfStoreKey . ') ' . $token);
59✔
1659
                        $fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey] = true;
59✔
1660
                    }
1661

1662
                    if (isset($storeKey)) {
61✔
1663
                        // We are processing an if condition
1664
                        // We cascade the pruning to the depending branches
1665
                        $branchStore[$storeKey] = 'Pruned branch';
3✔
1666
                        $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
3✔
1667
                        $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
3✔
1668
                    }
1669

1670
                    continue;
61✔
1671
                }
1672
            }
1673

1674
            if ($this->branchPruningEnabled && isset($tokenData['onlyIfNot'])) {
12,121✔
1675
                /** @var string */
1676
                $onlyIfNotStoreKey = $tokenData['onlyIfNot'];
79✔
1677
                $storeValue = $branchStore[$onlyIfNotStoreKey] ?? null;
79✔
1678
                $storeValueAsBool = ($storeValue === null)
79✔
1679
                    ? true : (bool) Functions::flattenSingleValue($storeValue);
79✔
1680
                if (is_array($storeValue)) {
79✔
1681
                    $wrappedItem = end($storeValue);
52✔
1682
                    $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
52✔
1683
                }
1684

1685
                if (
1686
                    (isset($storeValue) || $tokenData['reference'] === 'NULL')
79✔
1687
                    && ($storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
79✔
1688
                ) {
1689
                    // If branching value is true, we don't need to compute
1690
                    if (!isset($fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey])) {
57✔
1691
                        /** @var string $token */
1692
                        $stack->push('Value', 'Pruned branch (only if not ' . $onlyIfNotStoreKey . ') ' . $token);
57✔
1693
                        $fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey] = true;
57✔
1694
                    }
1695

1696
                    if (isset($storeKey)) {
57✔
1697
                        // We are processing an if condition
1698
                        // We cascade the pruning to the depending branches
1699
                        $branchStore[$storeKey] = 'Pruned branch';
10✔
1700
                        $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
10✔
1701
                        $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
10✔
1702
                    }
1703

1704
                    continue;
57✔
1705
                }
1706
            }
1707

1708
            if ($token instanceof Operands\StructuredReference) {
12,121✔
1709
                if ($cell === null) {
17✔
UNCOV
1710
                    return $this->raiseFormulaError('Structured References must exist in a Cell context');
×
1711
                }
1712

1713
                try {
1714
                    $cellRange = $token->parse($cell);
17✔
1715
                    if (str_contains($cellRange, ':')) {
17✔
1716
                        $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell Range %s', $token->value(), $cellRange);
8✔
1717
                        $rangeValue = self::getInstance($cell->getWorksheet()->getParent())->_calculateFormulaValue("={$cellRange}", $cellRange, $cell);
8✔
1718
                        $stack->push('Value', $rangeValue);
8✔
1719
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($rangeValue));
8✔
1720
                    } else {
1721
                        $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell %s', $token->value(), $cellRange);
10✔
1722
                        $cellValue = $cell->getWorksheet()->getCell($cellRange)->getCalculatedValue(false);
10✔
1723
                        $stack->push('Cell Reference', $cellValue, $cellRange);
10✔
1724
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($cellValue));
17✔
1725
                    }
1726
                } catch (Exception $e) {
2✔
1727
                    if ($e->getCode() === Exception::CALCULATION_ENGINE_PUSH_TO_STACK) {
2✔
1728
                        $stack->push('Error', ExcelError::REF(), null);
2✔
1729
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as error value %s', $token->value(), ExcelError::REF());
2✔
1730
                    } else {
UNCOV
1731
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1732
                    }
1733
                }
1734
            } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line
12,120✔
1735
                // if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
1736
                //    We must have two operands, error if we don't
1737
                $operand2Data = $stack->pop();
1,781✔
1738
                if ($operand2Data === null) {
1,781✔
UNCOV
1739
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
1740
                }
1741
                $operand1Data = $stack->pop();
1,781✔
1742
                if ($operand1Data === null) {
1,781✔
1743
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
1744
                }
1745

1746
                $operand1 = self::dataTestReference($operand1Data);
1,781✔
1747
                $operand2 = self::dataTestReference($operand2Data);
1,781✔
1748

1749
                //    Log what we're doing
1750
                if ($token == ':') {
1,781✔
1751
                    $this->debugLog->writeDebugLog('Evaluating Range %s %s %s', $this->showValue($operand1Data['reference']), $token, $this->showValue($operand2Data['reference']));
1,296✔
1752
                } else {
1753
                    $this->debugLog->writeDebugLog('Evaluating %s %s %s', $this->showValue($operand1), $token, $this->showValue($operand2));
766✔
1754
                }
1755

1756
                //    Process the operation in the appropriate manner
1757
                switch ($token) {
1758
                    // Comparison (Boolean) Operators
1759
                    case '>': // Greater than
1,781✔
1760
                    case '<': // Less than
1,766✔
1761
                    case '>=': // Greater than or Equal to
1,749✔
1762
                    case '<=': // Less than or Equal to
1,741✔
1763
                    case '=': // Equality
1,723✔
1764
                    case '<>': // Inequality
1,570✔
1765
                        $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack);
414✔
1766
                        if (isset($storeKey)) {
414✔
1767
                            $branchStore[$storeKey] = $result;
71✔
1768
                        }
1769

1770
                        break;
414✔
1771
                    // Binary Operators
1772
                    case ':': // Range
1,560✔
1773
                        if ($operand1Data['type'] === 'Error') {
1,296✔
1774
                            $stack->push($operand1Data['type'], $operand1Data['value'], null);
6✔
1775

1776
                            break;
6✔
1777
                        }
1778
                        if ($operand2Data['type'] === 'Error') {
1,291✔
1779
                            $stack->push($operand2Data['type'], $operand2Data['value'], null);
4✔
1780

1781
                            break;
4✔
1782
                        }
1783
                        if ($operand1Data['type'] === 'Defined Name') {
1,287✔
1784
                            /** @var array{reference: string} $operand1Data */
1785
                            if (preg_match('/$' . self::CALCULATION_REGEXP_DEFINEDNAME . '^/mui', $operand1Data['reference']) !== false && $this->spreadsheet !== null) {
3✔
1786
                                /** @var string[] $operand1Data */
1787
                                $definedName = $this->spreadsheet->getNamedRange($operand1Data['reference']);
3✔
1788
                                if ($definedName !== null) {
3✔
1789
                                    $operand1Data['reference'] = $operand1Data['value'] = str_replace('$', '', $definedName->getValue());
3✔
1790
                                }
1791
                            }
1792
                        }
1793
                        /** @var array{reference?: ?string} $operand1Data */
1794
                        if (str_contains($operand1Data['reference'] ?? '', '!')) {
1,287✔
1795
                            [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true, true);
1,279✔
1796
                        } else {
1797
                            $sheet1 = ($pCellWorksheet !== null) ? $pCellWorksheet->getTitle() : '';
12✔
1798
                        }
1799
                        //$sheet1 ??= ''; // phpstan level 10 says this is unneeded
1800

1801
                        /** @var string */
1802
                        $op2ref = $operand2Data['reference'];
1,287✔
1803
                        [$sheet2, $operand2Data['reference']] = Worksheet::extractSheetTitle($op2ref, true, true);
1,287✔
1804
                        if (empty($sheet2)) {
1,287✔
1805
                            $sheet2 = $sheet1;
7✔
1806
                        }
1807

1808
                        if ($sheet1 === $sheet2) {
1,287✔
1809
                            /** @var array{reference: ?string, value: string|string[]} $operand1Data */
1810
                            if ($operand1Data['reference'] === null && $cell !== null) {
1,284✔
UNCOV
1811
                                if (is_array($operand1Data['value'])) {
×
UNCOV
1812
                                    $operand1Data['reference'] = $cell->getCoordinate();
×
UNCOV
1813
                                } elseif ((trim($operand1Data['value']) != '') && (is_numeric($operand1Data['value']))) {
×
UNCOV
1814
                                    $operand1Data['reference'] = $cell->getColumn() . $operand1Data['value'];
×
1815
                                } elseif (trim($operand1Data['value']) == '') {
×
1816
                                    $operand1Data['reference'] = $cell->getCoordinate();
×
1817
                                } else {
1818
                                    $operand1Data['reference'] = $operand1Data['value'] . $cell->getRow();
×
1819
                                }
1820
                            }
1821
                            /** @var array{reference: ?string, value: string|string[]} $operand2Data */
1822
                            if ($operand2Data['reference'] === null && $cell !== null) {
1,284✔
1823
                                if (is_array($operand2Data['value'])) {
3✔
1824
                                    $operand2Data['reference'] = $cell->getCoordinate();
2✔
1825
                                } elseif ((trim($operand2Data['value']) != '') && (is_numeric($operand2Data['value']))) {
1✔
UNCOV
1826
                                    $operand2Data['reference'] = $cell->getColumn() . $operand2Data['value'];
×
1827
                                } elseif (trim($operand2Data['value']) == '') {
1✔
UNCOV
1828
                                    $operand2Data['reference'] = $cell->getCoordinate();
×
1829
                                } else {
1830
                                    $operand2Data['reference'] = $operand2Data['value'] . $cell->getRow();
1✔
1831
                                }
1832
                            }
1833

1834
                            $oData = array_merge(explode(':', $operand1Data['reference'] ?? ''), explode(':', $operand2Data['reference'] ?? ''));
1,284✔
1835
                            $oCol = $oRow = [];
1,284✔
1836
                            $breakNeeded = false;
1,284✔
1837
                            foreach ($oData as $oDatum) {
1,284✔
1838
                                try {
1839
                                    $oCR = Coordinate::coordinateFromString($oDatum);
1,284✔
1840
                                    $oCol[] = Coordinate::columnIndexFromString($oCR[0]) - 1;
1,284✔
1841
                                    $oRow[] = $oCR[1];
1,284✔
1842
                                } catch (\Exception) {
1✔
1843
                                    $stack->push('Error', ExcelError::REF(), null);
1✔
1844
                                    $breakNeeded = true;
1✔
1845

1846
                                    break;
1✔
1847
                                }
1848
                            }
1849
                            if ($breakNeeded) {
1,284✔
1850
                                break;
1✔
1851
                            }
1852
                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
1,283✔
1853
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
1,283✔
1854
                                $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
1,283✔
1855
                            } else {
UNCOV
1856
                                return $this->raiseFormulaError('Unable to access Cell Reference');
×
1857
                            }
1858

1859
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellValue));
1,283✔
1860
                            $stack->push('Cell Reference', $cellValue, $cellRef);
1,283✔
1861
                        } else {
1862
                            $this->debugLog->writeDebugLog('Evaluation Result is a #REF! Error');
4✔
1863
                            $stack->push('Error', ExcelError::REF(), null);
4✔
1864
                        }
1865

1866
                        break;
1,286✔
1867
                    case '+':            //    Addition
421✔
1868
                    case '-':            //    Subtraction
339✔
1869
                    case '*':            //    Multiplication
299✔
1870
                    case '/':            //    Division
170✔
1871
                    case '^':            //    Exponential
55✔
1872
                        $result = $this->executeNumericBinaryOperation($operand1, $operand2, $token, $stack);
383✔
1873
                        if (isset($storeKey)) {
383✔
1874
                            $branchStore[$storeKey] = $result;
6✔
1875
                        }
1876

1877
                        break;
383✔
1878
                    case '&':            //    Concatenation
51✔
1879
                        //    If either of the operands is a matrix, we need to treat them both as matrices
1880
                        //        (converting the other operand to a matrix if need be); then perform the required
1881
                        //        matrix operation
1882
                        $operand1 = self::boolToString($operand1);
28✔
1883
                        $operand2 = self::boolToString($operand2);
28✔
1884
                        if (is_array($operand1) || is_array($operand2)) {
28✔
1885
                            if (is_string($operand1)) {
17✔
1886
                                $operand1 = self::unwrapResult($operand1);
8✔
1887
                            }
1888
                            if (is_string($operand2)) {
17✔
1889
                                $operand2 = self::unwrapResult($operand2);
6✔
1890
                            }
1891
                            //    Ensure that both operands are arrays/matrices
1892
                            [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 2);
17✔
1893

1894
                            for ($row = 0; $row < $rows; ++$row) {
17✔
1895
                                for ($column = 0; $column < $columns; ++$column) {
17✔
1896
                                    /** @var mixed[][] $operand1 */
1897
                                    $op1x = self::boolToString($operand1[$row][$column]);
17✔
1898
                                    /** @var mixed[][] $operand2 */
1899
                                    $op2x = self::boolToString($operand2[$row][$column]);
17✔
1900
                                    if (Information\ErrorValue::isError($op1x)) {
17✔
1901
                                        // no need to do anything
1902
                                    } elseif (Information\ErrorValue::isError($op2x)) {
17✔
1903
                                        $operand1[$row][$column] = $op2x;
1✔
1904
                                    } else {
1905
                                        /** @var string $op1x */
1906
                                        /** @var string $op2x */
1907
                                        $operand1[$row][$column]
16✔
1908
                                            = StringHelper::substring(
16✔
1909
                                                $op1x . $op2x,
16✔
1910
                                                0,
16✔
1911
                                                DataType::MAX_STRING_LENGTH
16✔
1912
                                            );
16✔
1913
                                    }
1914
                                }
1915
                            }
1916
                            $result = $operand1;
17✔
1917
                        } else {
1918
                            if (Information\ErrorValue::isError($operand1)) {
13✔
UNCOV
1919
                                $result = $operand1;
×
1920
                            } elseif (Information\ErrorValue::isError($operand2)) {
13✔
UNCOV
1921
                                $result = $operand2;
×
1922
                            } else {
1923
                                $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line
13✔
1924
                                $result = StringHelper::substring(
13✔
1925
                                    $result,
13✔
1926
                                    0,
13✔
1927
                                    DataType::MAX_STRING_LENGTH
13✔
1928
                                );
13✔
1929
                                $result = self::FORMULA_STRING_QUOTE . $result . self::FORMULA_STRING_QUOTE;
13✔
1930
                            }
1931
                        }
1932
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
28✔
1933
                        $stack->push('Value', $result);
28✔
1934

1935
                        if (isset($storeKey)) {
28✔
UNCOV
1936
                            $branchStore[$storeKey] = $result;
×
1937
                        }
1938

1939
                        break;
28✔
1940
                    case '∩':            //    Intersect
23✔
1941
                        /** @var mixed[][] $operand1 */
1942
                        /** @var mixed[][] $operand2 */
1943
                        $rowIntersect = array_intersect_key($operand1, $operand2);
17✔
1944
                        $cellIntersect = $oCol = $oRow = [];
17✔
1945
                        foreach (array_keys($rowIntersect) as $row) {
17✔
1946
                            $oRow[] = $row;
17✔
1947
                            foreach ($rowIntersect[$row] as $col => $data) {
17✔
1948
                                $oCol[] = Coordinate::columnIndexFromString($col) - 1;
17✔
1949
                                $cellIntersect[$row] = array_intersect_key($operand1[$row], $operand2[$row]);
17✔
1950
                            }
1951
                        }
1952
                        if (count(Functions::flattenArray($cellIntersect)) === 0) {
17✔
1953
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
2✔
1954
                            $stack->push('Error', ExcelError::null(), null);
2✔
1955
                        } else {
1956
                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
15✔
1957
                                . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
15✔
1958
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
15✔
1959
                            $stack->push('Value', $cellIntersect, $cellRef);
15✔
1960
                        }
1961

1962
                        break;
17✔
1963
                    case '∪':            //    union
8✔
1964
                        /** @var mixed[][] $operand1 */
1965
                        /** @var mixed[][] $operand2 */
1966
                        $cellUnion = array_merge($operand1, $operand2);
8✔
1967
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellUnion));
8✔
1968
                        $stack->push('Value', $cellUnion, 'A1');
8✔
1969

1970
                        break;
8✔
1971
                }
1972
            } elseif (($token === '~') || ($token === '%')) {
12,113✔
1973
                // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
1974
                if (($arg = $stack->pop()) === null) {
1,169✔
UNCOV
1975
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
1976
                }
1977
                $arg = $arg['value'];
1,169✔
1978
                if ($token === '~') {
1,169✔
1979
                    $this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
1,165✔
1980
                    $multiplier = -1;
1,165✔
1981
                } else {
1982
                    $this->debugLog->writeDebugLog('Evaluating Percentile of %s', $this->showValue($arg));
6✔
1983
                    $multiplier = 0.01;
6✔
1984
                }
1985
                if (is_array($arg)) {
1,169✔
1986
                    $operand2 = $multiplier;
4✔
1987
                    $result = $arg;
4✔
1988
                    [$rows, $columns] = self::checkMatrixOperands($result, $operand2, 0);
4✔
1989
                    for ($row = 0; $row < $rows; ++$row) {
4✔
1990
                        for ($column = 0; $column < $columns; ++$column) {
4✔
1991
                            /** @var mixed[][] $result */
1992
                            if (self::isNumericOrBool($result[$row][$column])) {
4✔
1993
                                /** @var float|int|numeric-string */
1994
                                $temp = $result[$row][$column];
4✔
1995
                                $result[$row][$column] = $temp * $multiplier;
4✔
1996
                            } else {
1997
                                $result[$row][$column] = self::makeError($result[$row][$column]);
2✔
1998
                            }
1999
                        }
2000
                    }
2001

2002
                    $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
4✔
2003
                    $stack->push('Value', $result);
4✔
2004
                    if (isset($storeKey)) {
4✔
UNCOV
2005
                        $branchStore[$storeKey] = $result;
×
2006
                    }
2007
                } else {
2008
                    $this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
1,168✔
2009
                }
2010
            } elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
12,113✔
2011
                $cellRef = null;
7,218✔
2012

2013
                /* Phpstan says matches[8/9/10] is never set,
2014
                   and code coverage report seems to confirm.
2015
                   regex101.com confirms - only 7 capturing groups.
2016
                   My theory is that this code expected regexp to
2017
                   match cell *or* cellRange, but it does not
2018
                   match the latter. Retain the code for now in case
2019
                   we do want to add the range match later.
2020
                   Probably delete this block later.
2021
                   Until delete happens, turn code coverage off.
2022
                */
2023
                if (isset($matches[self::$matchIndex8])) {
7,218✔
2024
                    // @codeCoverageIgnoreStart
2025
                    if ($cell === null) {
2026
                        // We can't access the range, so return a REF error
2027
                        $cellValue = ExcelError::REF();
2028
                    } else {
2029
                        $cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
2030
                        $matches[2] = (string) $matches[2];
2031
                        if ($matches[2] > '') {
2032
                            $matches[2] = trim($matches[2], "\"'");
2033
                            if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
2034
                                //    It's a Reference to an external spreadsheet (not currently supported)
2035
                                return $this->raiseFormulaError('Unable to access External Workbook');
2036
                            }
2037
                            $matches[2] = trim($matches[2], "\"'");
2038
                            $this->debugLog->writeDebugLog('Evaluating Cell Range %s in worksheet %s', $cellRef, $matches[2]);
2039
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
2040
                                $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
2041
                            } else {
2042
                                return $this->raiseFormulaError('Unable to access Cell Reference');
2043
                            }
2044
                            $this->debugLog->writeDebugLog('Evaluation Result for cells %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
2045
                        } else {
2046
                            $this->debugLog->writeDebugLog('Evaluating Cell Range %s in current worksheet', $cellRef);
2047
                            if ($pCellParent !== null) {
2048
                                $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
2049
                            } else {
2050
                                return $this->raiseFormulaError('Unable to access Cell Reference');
2051
                            }
2052
                            $this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
2053
                        }
2054
                    }
2055
                    // @codeCoverageIgnoreEnd
2056
                } else {
2057
                    if ($cell === null) {
7,218✔
2058
                        // We can't access the cell, so return a REF error
NEW
2059
                        $cellValue = ExcelError::REF();
×
2060
                    } else {
2061
                        $cellRef = $matches[6] . $matches[7];
7,218✔
2062
                        $matches[2] = (string) $matches[2];
7,218✔
2063
                        if ($matches[2] > '') {
7,218✔
2064
                            $matches[2] = trim($matches[2], "\"'");
7,211✔
2065
                            if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
7,211✔
2066
                                //    It's a Reference to an external spreadsheet (not currently supported)
2067
                                return $this->raiseFormulaError('Unable to access External Workbook');
1✔
2068
                            }
2069
                            $this->debugLog->writeDebugLog('Evaluating Cell %s in worksheet %s', $cellRef, $matches[2]);
7,211✔
2070
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
7,211✔
2071
                                $cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
7,211✔
2072
                                if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
7,211✔
2073
                                    $cellSheet->setCellValue($cellRef, null);
338✔
2074
                                }
2075
                                if ($cellSheet && $cellSheet->cellExists($cellRef)) {
7,211✔
2076
                                    $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
7,203✔
2077
                                    $cell->attach($pCellParent);
7,203✔
2078
                                } else {
2079
                                    $cellRef = ($cellSheet !== null) ? "'{$matches[2]}'!{$cellRef}" : $cellRef;
21✔
2080
                                    $cellValue = ($cellSheet !== null) ? null : ExcelError::REF();
21✔
2081
                                }
2082
                            } else {
UNCOV
2083
                                return $this->raiseFormulaError('Unable to access Cell Reference');
×
2084
                            }
2085
                            $this->debugLog->writeDebugLog('Evaluation Result for cell %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
7,211✔
2086
                        } else {
2087
                            $this->debugLog->writeDebugLog('Evaluating Cell %s in current worksheet', $cellRef);
11✔
2088
                            if ($pCellParent !== null && $pCellParent->has($cellRef)) {
11✔
2089
                                $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
11✔
2090
                                $cell->attach($pCellParent);
11✔
2091
                            } else {
2092
                                $cellValue = null;
2✔
2093
                            }
2094
                            $this->debugLog->writeDebugLog('Evaluation Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
11✔
2095
                        }
2096
                    }
2097
                }
2098

2099
                if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY && !$this->processingAnchorArray && is_array($cellValue)) {
7,218✔
2100
                    while (is_array($cellValue)) {
213✔
2101
                        $cellValue = array_shift($cellValue);
213✔
2102
                    }
2103
                    if (is_string($cellValue)) {
213✔
2104
                        $cellValue = Preg::replace('/"/', '""', $cellValue);
139✔
2105
                    }
2106
                    $this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
213✔
2107
                }
2108
                $this->processingAnchorArray = false;
7,218✔
2109
                $stack->push('Cell Value', $cellValue, $cellRef);
7,218✔
2110
                if (isset($storeKey)) {
7,218✔
2111
                    $branchStore[$storeKey] = $cellValue;
59✔
2112
                }
2113
            } elseif (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($token ?? ''), $matches)) {
12,029✔
2114
                // if the token is a function, pop arguments off the stack, hand them to the function, and push the result back on
2115
                if ($cell !== null && $pCellParent !== null) {
11,824✔
2116
                    $cell->attach($pCellParent);
8,233✔
2117
                }
2118

2119
                $functionName = $matches[1];
11,824✔
2120
                /** @var array<string, int> $argCount */
2121
                $argCount = $stack->pop();
11,824✔
2122
                $argCount = $argCount['value'];
11,824✔
2123
                if ($functionName !== 'MKMATRIX') {
11,824✔
2124
                    $this->debugLog->writeDebugLog('Evaluating Function %s() with %s argument%s', self::localeFunc($functionName), (($argCount == 0) ? 'no' : $argCount), (($argCount == 1) ? '' : 's'));
11,823✔
2125
                }
2126
                if ((isset($phpSpreadsheetFunctions[$functionName])) || (isset(self::$controlFunctions[$functionName]))) {    // function
11,824✔
2127
                    $passByReference = false;
11,824✔
2128
                    $passCellReference = false;
11,824✔
2129
                    $functionCall = null;
11,824✔
2130
                    if (isset($phpSpreadsheetFunctions[$functionName])) {
11,824✔
2131
                        $functionCall = $phpSpreadsheetFunctions[$functionName]['functionCall'];
11,821✔
2132
                        $passByReference = isset($phpSpreadsheetFunctions[$functionName]['passByReference']);
11,821✔
2133
                        $passCellReference = isset($phpSpreadsheetFunctions[$functionName]['passCellReference']);
11,821✔
2134
                    } elseif (isset(self::$controlFunctions[$functionName])) {
844✔
2135
                        $functionCall = self::$controlFunctions[$functionName]['functionCall'];
844✔
2136
                        $passByReference = isset(self::$controlFunctions[$functionName]['passByReference']);
844✔
2137
                        $passCellReference = isset(self::$controlFunctions[$functionName]['passCellReference']);
844✔
2138
                    }
2139

2140
                    // get the arguments for this function
2141
                    $args = $argArrayVals = [];
11,824✔
2142
                    $emptyArguments = [];
11,824✔
2143
                    for ($i = 0; $i < $argCount; ++$i) {
11,824✔
2144
                        $arg = $stack->pop();
11,806✔
2145
                        $a = $argCount - $i - 1;
11,806✔
2146
                        if (
2147
                            ($passByReference)
11,806✔
2148
                            && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line
11,806✔
2149
                            && ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
11,806✔
2150
                        ) {
2151
                            /** @var mixed[] $arg */
2152
                            if ($arg['reference'] === null) {
82✔
2153
                                $nextArg = $cellID;
19✔
2154
                                if ($functionName === 'ISREF' && ($arg['type'] ?? '') === 'Value') {
19✔
2155
                                    if (array_key_exists('value', $arg)) {
5✔
2156
                                        $argValue = $arg['value'];
5✔
2157
                                        if (is_scalar($argValue)) {
5✔
2158
                                            $nextArg = $argValue;
2✔
2159
                                        } elseif (empty($argValue)) {
3✔
2160
                                            $nextArg = '';
1✔
2161
                                        }
2162
                                    }
2163
                                } elseif (($arg['type'] ?? '') === 'Error') {
14✔
2164
                                    $argValue = $arg['value'];
13✔
2165
                                    if (is_scalar($argValue)) {
13✔
2166
                                        $nextArg = $argValue;
13✔
UNCOV
2167
                                    } elseif (empty($argValue)) {
×
UNCOV
2168
                                        $nextArg = '';
×
2169
                                    }
2170
                                }
2171
                                $args[] = $nextArg;
19✔
2172
                                if ($functionName !== 'MKMATRIX') {
19✔
2173
                                    $argArrayVals[] = $this->showValue($cellID);
19✔
2174
                                }
2175
                            } else {
2176
                                $args[] = $arg['reference'];
67✔
2177
                                if ($functionName !== 'MKMATRIX') {
67✔
2178
                                    $argArrayVals[] = $this->showValue($arg['reference']);
67✔
2179
                                }
2180
                            }
2181
                        } else {
2182
                            /** @var mixed[] $arg */
2183
                            if ($arg['type'] === 'Empty Argument' && in_array($functionName, ['MIN', 'MINA', 'MAX', 'MAXA', 'IF'], true)) {
11,750✔
2184
                                $emptyArguments[] = false;
15✔
2185
                                $args[] = $arg['value'] = 0;
15✔
2186
                                $this->debugLog->writeDebugLog('Empty Argument reevaluated as 0');
15✔
2187
                            } else {
2188
                                $emptyArguments[] = $arg['type'] === 'Empty Argument';
11,750✔
2189
                                $args[] = self::unwrapResult($arg['value']);
11,750✔
2190
                            }
2191
                            if ($functionName !== 'MKMATRIX') {
11,750✔
2192
                                $argArrayVals[] = $this->showValue($arg['value']);
11,749✔
2193
                            }
2194
                        }
2195
                    }
2196

2197
                    //    Reverse the order of the arguments
2198
                    krsort($args);
11,824✔
2199
                    krsort($emptyArguments);
11,824✔
2200

2201
                    if ($argCount > 0 && is_array($functionCall)) {
11,824✔
2202
                        /** @var string[] */
2203
                        $functionCallCopy = $functionCall;
11,806✔
2204
                        $args = $this->addDefaultArgumentValues($functionCallCopy, $args, $emptyArguments);
11,806✔
2205
                    }
2206

2207
                    if (($passByReference) && ($argCount == 0)) {
11,824✔
2208
                        $args[] = $cellID;
9✔
2209
                        $argArrayVals[] = $this->showValue($cellID);
9✔
2210
                    }
2211

2212
                    if ($functionName !== 'MKMATRIX') {
11,824✔
2213
                        if ($this->debugLog->getWriteDebugLog()) {
11,823✔
2214
                            krsort($argArrayVals);
2✔
2215
                            $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
2✔
2216
                        }
2217
                    }
2218

2219
                    //    Process the argument with the appropriate function call
2220
                    if ($pCellWorksheet !== null && $originalCoordinate !== null) {
11,824✔
2221
                        $pCellWorksheet->getCell($originalCoordinate);
8,233✔
2222
                    }
2223
                    /** @var array<string>|string $functionCall */
2224
                    $args = $this->addCellReference($args, $passCellReference, $functionCall, $cell);
11,824✔
2225

2226
                    if (!is_array($functionCall)) {
11,824✔
2227
                        foreach ($args as &$arg) {
54✔
2228
                            $arg = Functions::flattenSingleValue($arg);
1✔
2229
                        }
2230
                        unset($arg);
54✔
2231
                    }
2232

2233
                    /** @var callable $functionCall */
2234
                    try {
2235
                        $result = call_user_func_array($functionCall, $args);
11,824✔
2236
                    } catch (TypeError $e) {
12✔
UNCOV
2237
                        if (!$this->suppressFormulaErrors) {
×
UNCOV
2238
                            throw $e;
×
2239
                        }
UNCOV
2240
                        $result = false;
×
2241
                    }
2242
                    if ($functionName !== 'MKMATRIX') {
11,819✔
2243
                        $this->debugLog->writeDebugLog('Evaluation Result for %s() function call is %s', self::localeFunc($functionName), $this->showTypeDetails($result));
11,816✔
2244
                    }
2245
                    $stack->push('Value', self::wrapResult($result));
11,819✔
2246
                    if (isset($storeKey)) {
11,819✔
2247
                        $branchStore[$storeKey] = $result;
23✔
2248
                    }
2249
                }
2250
            } else {
2251
                // if the token is a number, boolean, string or an Excel error, push it onto the stack
2252
                /** @var ?string $token */
2253
                if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
12,029✔
UNCOV
2254
                    $excelConstant = strtoupper("$token");
×
UNCOV
2255
                    $stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
×
UNCOV
2256
                    if (isset($storeKey)) {
×
UNCOV
2257
                        $branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
×
2258
                    }
2259
                    $this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
×
2260
                } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line
12,029✔
2261
                    /** @var array{type: string, reference: ?string} $tokenData */
2262
                    $stack->push($tokenData['type'], $token, $tokenData['reference']);
11,990✔
2263
                    if (isset($storeKey)) {
11,990✔
2264
                        $branchStore[$storeKey] = $token;
74✔
2265
                    }
2266
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/miu', $token, $matches)) {
161✔
2267
                    // if the token is a named range or formula, evaluate it and push the result onto the stack
2268
                    $definedName = $matches[6];
161✔
2269
                    if (str_starts_with($definedName, '_xleta')) {
161✔
2270
                        return Functions::NOT_YET_IMPLEMENTED;
1✔
2271
                    }
2272
                    if ($cell === null || $pCellWorksheet === null) {
160✔
UNCOV
2273
                        return $this->raiseFormulaError("undefined name '$token'");
×
2274
                    }
2275
                    $specifiedWorksheet = trim($matches[2], "'");
160✔
2276

2277
                    $this->debugLog->writeDebugLog('Evaluating Defined Name %s', $definedName);
160✔
2278
                    $namedRange = DefinedName::resolveName($definedName, $pCellWorksheet, $specifiedWorksheet);
160✔
2279
                    // If not Defined Name, try as Table.
2280
                    if ($namedRange === null && $this->spreadsheet !== null) {
160✔
2281
                        $table = $this->spreadsheet->getTableByName($definedName);
38✔
2282
                        if ($table !== null) {
38✔
2283
                            $tableRange = Coordinate::getRangeBoundaries($table->getRange());
3✔
2284
                            if ($table->getShowHeaderRow()) {
3✔
2285
                                ++$tableRange[0][1];
3✔
2286
                            }
2287
                            if ($table->getShowTotalsRow()) {
3✔
UNCOV
2288
                                --$tableRange[1][1];
×
2289
                            }
2290
                            $tableRangeString
3✔
2291
                                = '$' . $tableRange[0][0]
3✔
2292
                                . '$' . $tableRange[0][1]
3✔
2293
                                . ':'
3✔
2294
                                . '$' . $tableRange[1][0]
3✔
2295
                                . '$' . $tableRange[1][1];
3✔
2296
                            $namedRange = new NamedRange($definedName, $table->getWorksheet(), $tableRangeString);
3✔
2297
                        }
2298
                    }
2299
                    if ($namedRange === null) {
160✔
2300
                        $result = ExcelError::NAME();
35✔
2301
                        $stack->push('Error', $result, null);
35✔
2302
                        $this->debugLog->writeDebugLog("Error $result");
35✔
2303
                    } else {
2304
                        $result = $this->evaluateDefinedName($cell, $namedRange, $pCellWorksheet, $stack, $specifiedWorksheet !== '');
137✔
2305
                    }
2306

2307
                    if (isset($storeKey)) {
160✔
2308
                        $branchStore[$storeKey] = $result;
1✔
2309
                    }
2310
                } else {
UNCOV
2311
                    return $this->raiseFormulaError("undefined name '$token'");
×
2312
                }
2313
            }
2314
        }
2315
        // when we're out of tokens, the stack should have a single element, the final result
2316
        if ($stack->count() != 1) {
12,112✔
2317
            return $this->raiseFormulaError('internal error');
1✔
2318
        }
2319
        /** @var array<string, array<int, mixed>|false|string> */
2320
        $output = $stack->pop();
12,112✔
2321
        $output = $output['value'];
12,112✔
2322

2323
        return $output;
12,112✔
2324
    }
2325

2326
    private function validateBinaryOperand(mixed &$operand, Stack &$stack): bool
1,484✔
2327
    {
2328
        if (is_array($operand)) {
1,484✔
2329
            if ((count($operand, COUNT_RECURSIVE) - count($operand)) == 1) {
228✔
2330
                do {
2331
                    $operand = array_pop($operand);
193✔
2332
                } while (is_array($operand));
193✔
2333
            }
2334
        }
2335
        //    Numbers, matrices and booleans can pass straight through, as they're already valid
2336
        if (is_string($operand)) {
1,484✔
2337
            //    We only need special validations for the operand if it is a string
2338
            //    Start by stripping off the quotation marks we use to identify true excel string values internally
2339
            if ($operand > '' && $operand[0] == self::FORMULA_STRING_QUOTE) {
17✔
2340
                $operand = StringHelper::convertToString(self::unwrapResult($operand));
5✔
2341
            }
2342
            //    If the string is a numeric value, we treat it as a numeric, so no further testing
2343
            if (!is_numeric($operand)) {
17✔
2344
                //    If not a numeric, test to see if the value is an Excel error, and so can't be used in normal binary operations
2345
                if ($operand > '' && $operand[0] == '#') {
15✔
2346
                    $stack->push('Value', $operand);
6✔
2347
                    $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($operand));
6✔
2348

2349
                    return false;
6✔
2350
                } elseif (Engine\FormattedNumber::convertToNumberIfFormatted($operand) === false) {
11✔
2351
                    //    If not a numeric, a fraction or a percentage, then it's a text string, and so can't be used in mathematical binary operations
2352
                    $stack->push('Error', '#VALUE!');
6✔
2353
                    $this->debugLog->writeDebugLog('Evaluation Result is a %s', $this->showTypeDetails('#VALUE!'));
6✔
2354

2355
                    return false;
6✔
2356
                }
2357
            }
2358
        }
2359

2360
        //    return a true if the value of the operand is one that we can use in normal binary mathematical operations
2361
        return true;
1,482✔
2362
    }
2363

2364
    /** @return mixed[] */
2365
    private function executeArrayComparison(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays): array
56✔
2366
    {
2367
        $result = [];
56✔
2368
        if (!is_array($operand2) && is_array($operand1)) {
56✔
2369
            // Operand 1 is an array, Operand 2 is a scalar
2370
            foreach ($operand1 as $x => $operandData) {
53✔
2371
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2));
53✔
2372
                $this->executeBinaryComparisonOperation($operandData, $operand2, $operation, $stack);
53✔
2373
                /** @var array<string, mixed> $r */
2374
                $r = $stack->pop();
53✔
2375
                $result[$x] = $r['value'];
53✔
2376
            }
2377
        } elseif (is_array($operand2) && !is_array($operand1)) {
10✔
2378
            // Operand 1 is a scalar, Operand 2 is an array
2379
            foreach ($operand2 as $x => $operandData) {
1✔
2380
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operand1), $operation, $this->showValue($operandData));
1✔
2381
                $this->executeBinaryComparisonOperation($operand1, $operandData, $operation, $stack);
1✔
2382
                /** @var array<string, mixed> $r */
2383
                $r = $stack->pop();
1✔
2384
                $result[$x] = $r['value'];
1✔
2385
            }
2386
        } elseif (is_array($operand2) && is_array($operand1)) {
9✔
2387
            // Operand 1 and Operand 2 are both arrays
2388
            if (!$recursingArrays) {
9✔
2389
                self::checkMatrixOperands($operand1, $operand2, 2);
9✔
2390
            }
2391
            foreach ($operand1 as $x => $operandData) {
9✔
2392
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2[$x]));
9✔
2393
                $this->executeBinaryComparisonOperation($operandData, $operand2[$x], $operation, $stack, true);
9✔
2394
                /** @var array<string, mixed> $r */
2395
                $r = $stack->pop();
9✔
2396
                $result[$x] = $r['value'];
9✔
2397
            }
2398
        } else {
UNCOV
2399
            throw new Exception('Neither operand is an arra');
×
2400
        }
2401
        //    Log the result details
2402
        $this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
56✔
2403
        //    And push the result onto the stack
2404
        $stack->push('Array', $result);
56✔
2405

2406
        return $result;
56✔
2407
    }
2408

2409
    /** @return array<mixed>|bool|string */
2410
    private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
414✔
2411
    {
2412
        //    If we're dealing with matrix operations, we want a matrix result
2413
        if ((is_array($operand1)) || (is_array($operand2))) {
414✔
2414
            return $this->executeArrayComparison($operand1, $operand2, $operation, $stack, $recursingArrays);
56✔
2415
        }
2416

2417
        $result = BinaryComparison::compare($operand1, $operand2, $operation);
414✔
2418

2419
        //    Log the result details
2420
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
414✔
2421
        //    And push the result onto the stack
2422
        $stack->push('Value', $result);
414✔
2423

2424
        return $result;
414✔
2425
    }
2426

2427
    private function executeNumericBinaryOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack): mixed
1,484✔
2428
    {
2429
        //    Validate the two operands
2430
        if (
2431
            ($this->validateBinaryOperand($operand1, $stack) === false)
1,484✔
2432
            || ($this->validateBinaryOperand($operand2, $stack) === false)
1,484✔
2433
        ) {
2434
            return false;
10✔
2435
        }
2436

2437
        if (
2438
            (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE)
1,477✔
2439
            && ((is_string($operand1) && !is_numeric($operand1) && $operand1 !== '')
1,477✔
2440
                || (is_string($operand2) && !is_numeric($operand2) && $operand2 !== ''))
1,477✔
2441
        ) {
UNCOV
2442
            $result = ExcelError::VALUE();
×
2443
        } elseif (is_array($operand1) || is_array($operand2)) {
1,477✔
2444
            //    Ensure that both operands are arrays/matrices
2445
            if (is_array($operand1)) {
38✔
2446
                foreach ($operand1 as $key => $value) {
32✔
2447
                    $operand1[$key] = Functions::flattenArray($value);
32✔
2448
                }
2449
            }
2450
            if (is_array($operand2)) {
38✔
2451
                foreach ($operand2 as $key => $value) {
31✔
2452
                    $operand2[$key] = Functions::flattenArray($value);
31✔
2453
                }
2454
            }
2455
            [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 3);
38✔
2456

2457
            for ($row = 0; $row < $rows; ++$row) {
38✔
2458
                for ($column = 0; $column < $columns; ++$column) {
38✔
2459
                    /** @var mixed[][] $operand1 */
2460
                    if (($operand1[$row][$column] ?? null) === null) {
38✔
2461
                        $operand1[$row][$column] = 0;
2✔
2462
                    } elseif (!self::isNumericOrBool($operand1[$row][$column])) {
38✔
2463
                        $operand1[$row][$column] = self::makeError($operand1[$row][$column]);
1✔
2464

2465
                        continue;
1✔
2466
                    }
2467
                    /** @var mixed[][] $operand2 */
2468
                    if (($operand2[$row][$column] ?? null) === null) {
38✔
2469
                        $operand2[$row][$column] = 0;
2✔
2470
                    } elseif (!self::isNumericOrBool($operand2[$row][$column])) {
38✔
UNCOV
2471
                        $operand1[$row][$column] = self::makeError($operand2[$row][$column]);
×
2472

UNCOV
2473
                        continue;
×
2474
                    }
2475
                    /** @var float|int */
2476
                    $operand1Val = $operand1[$row][$column];
38✔
2477
                    /** @var float|int */
2478
                    $operand2Val = $operand2[$row][$column];
38✔
2479
                    switch ($operation) {
2480
                        case '+':
38✔
2481
                            $operand1[$row][$column] = $operand1Val + $operand2Val;
4✔
2482

2483
                            break;
4✔
2484
                        case '-':
35✔
2485
                            $operand1[$row][$column] = $operand1Val - $operand2Val;
4✔
2486

2487
                            break;
4✔
2488
                        case '*':
32✔
2489
                            $operand1[$row][$column] = $operand1Val * $operand2Val;
25✔
2490

2491
                            break;
25✔
2492
                        case '/':
7✔
2493
                            if ($operand2Val == 0) {
5✔
2494
                                $operand1[$row][$column] = ExcelError::DIV0();
3✔
2495
                            } else {
2496
                                $operand1[$row][$column] = $operand1Val / $operand2Val;
4✔
2497
                            }
2498

2499
                            break;
5✔
2500
                        case '^':
2✔
2501
                            $operand1[$row][$column] = $operand1Val ** $operand2Val;
2✔
2502

2503
                            break;
2✔
2504

2505
                        default:
UNCOV
2506
                            throw new Exception('Unsupported numeric binary operation');
×
2507
                    }
2508
                }
2509
            }
2510
            $result = $operand1;
38✔
2511
        } else {
2512
            //    If we're dealing with non-matrix operations, execute the necessary operation
2513
            /** @var float|int $operand1 */
2514
            /** @var float|int $operand2 */
2515
            switch ($operation) {
2516
                //    Addition
2517
                case '+':
1,459✔
2518
                    $result = $operand1 + $operand2;
163✔
2519

2520
                    break;
163✔
2521
                //    Subtraction
2522
                case '-':
1,378✔
2523
                    $result = $operand1 - $operand2;
51✔
2524

2525
                    break;
51✔
2526
                //    Multiplication
2527
                case '*':
1,343✔
2528
                    $result = $operand1 * $operand2;
1,265✔
2529

2530
                    break;
1,265✔
2531
                //    Division
2532
                case '/':
115✔
2533
                    if ($operand2 == 0) {
113✔
2534
                        //    Trap for Divide by Zero error
2535
                        $stack->push('Error', ExcelError::DIV0());
62✔
2536
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails(ExcelError::DIV0()));
62✔
2537

2538
                        return false;
62✔
2539
                    }
2540
                    $result = $operand1 / $operand2;
61✔
2541

2542
                    break;
61✔
2543
                //    Power
2544
                case '^':
3✔
2545
                    $result = $operand1 ** $operand2;
3✔
2546

2547
                    break;
3✔
2548

2549
                default:
UNCOV
2550
                    throw new Exception('Unsupported numeric binary operation');
×
2551
            }
2552
        }
2553

2554
        //    Log the result details
2555
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
1,433✔
2556
        //    And push the result onto the stack
2557
        $stack->push('Value', $result);
1,433✔
2558

2559
        return $result;
1,433✔
2560
    }
2561

2562
    /**
2563
     * Trigger an error, but nicely, if need be.
2564
     *
2565
     * @return false
2566
     */
2567
    protected function raiseFormulaError(string $errorMessage, int $code = 0, ?Throwable $exception = null): bool
246✔
2568
    {
2569
        $this->formulaError = $errorMessage;
246✔
2570
        $this->cyclicReferenceStack->clear();
246✔
2571
        $suppress = $this->suppressFormulaErrors;
246✔
2572
        $suppressed = $suppress ? ' $suppressed' : '';
246✔
2573
        $this->debugLog->writeDebugLog("Raise Error$suppressed $errorMessage");
246✔
2574
        if (!$suppress) {
246✔
2575
            throw new Exception($errorMessage, $code, $exception);
245✔
2576
        }
2577

2578
        return false;
2✔
2579
    }
2580

2581
    /**
2582
     * Extract range values.
2583
     *
2584
     * @param string $range String based range representation
2585
     * @param ?Worksheet $worksheet Worksheet
2586
     * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2587
     *
2588
     * @return mixed[] Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2589
     */
2590
    public function extractCellRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true, bool $createCell = false): array
7,257✔
2591
    {
2592
        // Return value
2593
        /** @var mixed[][] */
2594
        $returnValue = [];
7,257✔
2595

2596
        if ($worksheet !== null) {
7,257✔
2597
            $worksheetName = $worksheet->getTitle();
7,256✔
2598

2599
            if (str_contains($range, '!')) {
7,256✔
2600
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
10✔
2601
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
10✔
2602
            }
2603

2604
            // Extract range
2605
            $aReferences = Coordinate::extractAllCellReferencesInRange($range);
7,256✔
2606
            $range = "'" . $worksheetName . "'" . '!' . $range;
7,256✔
2607
            $currentCol = '';
7,256✔
2608
            $currentRow = 0;
7,256✔
2609
            if (!isset($aReferences[1])) {
7,256✔
2610
                //    Single cell in range
2611
                sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
7,252✔
2612
                /** @var string $currentCol */
2613
                /** @var int $currentRow */
2614
                if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
7,252✔
2615
                    $worksheet->setCellValue($aReferences[0], null);
2✔
2616
                }
2617
                if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
7,252✔
2618
                    $temp = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
7,252✔
2619
                    if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
7,252✔
2620
                        while (is_array($temp)) {
215✔
2621
                            $temp = array_shift($temp);
8✔
2622
                        }
2623
                    }
2624
                    $returnValue[$currentRow][$currentCol] = $temp;
7,252✔
2625
                } else {
UNCOV
2626
                    $returnValue[$currentRow][$currentCol] = null;
×
2627
                }
2628
            } else {
2629
                // Extract cell data for all cells in the range
2630
                foreach ($aReferences as $reference) {
1,282✔
2631
                    // Extract range
2632
                    sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
1,282✔
2633
                    /** @var string $currentCol */
2634
                    /** @var int $currentRow */
2635
                    if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
1,282✔
2636
                        $worksheet->setCellValue($reference, null);
4✔
2637
                    }
2638
                    if ($worksheet !== null && $worksheet->cellExists($reference)) {
1,282✔
2639
                        $temp = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
1,280✔
2640
                        if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
1,280✔
2641
                            while (is_array($temp)) {
190✔
2642
                                $temp = array_shift($temp);
1✔
2643
                            }
2644
                        }
2645
                        $returnValue[$currentRow][$currentCol] = $temp;
1,280✔
2646
                    } else {
2647
                        $returnValue[$currentRow][$currentCol] = null;
137✔
2648
                    }
2649
                }
2650
            }
2651
        }
2652

2653
        return $returnValue;
7,257✔
2654
    }
2655

2656
    /**
2657
     * Extract range values.
2658
     *
2659
     * @param string $range String based range representation
2660
     * @param null|Worksheet $worksheet Worksheet
2661
     * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2662
     *
2663
     * @return mixed[]|string Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2664
     */
2665
    public function extractNamedRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true): string|array
1✔
2666
    {
2667
        // Return value
2668
        $returnValue = [];
1✔
2669

2670
        if ($worksheet !== null) {
1✔
2671
            if (str_contains($range, '!')) {
1✔
2672
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
1✔
2673
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
1✔
2674
            }
2675

2676
            // Named range?
2677
            $namedRange = ($worksheet === null) ? null : DefinedName::resolveName($range, $worksheet);
1✔
2678
            if ($namedRange === null) {
1✔
2679
                return ExcelError::REF();
1✔
2680
            }
2681

2682
            $worksheet = $namedRange->getWorksheet();
1✔
2683
            $range = $namedRange->getValue();
1✔
2684
            $splitRange = Coordinate::splitRange($range);
1✔
2685
            //    Convert row and column references
2686
            if ($worksheet !== null && ctype_alpha($splitRange[0][0])) {
1✔
UNCOV
2687
                $range = $splitRange[0][0] . '1:' . $splitRange[0][1] . $worksheet->getHighestRow();
×
2688
            } elseif ($worksheet !== null && ctype_digit($splitRange[0][0])) {
1✔
UNCOV
2689
                $range = 'A' . $splitRange[0][0] . ':' . $worksheet->getHighestColumn() . $splitRange[0][1];
×
2690
            }
2691

2692
            // Extract range
2693
            $aReferences = Coordinate::extractAllCellReferencesInRange($range);
1✔
2694
            if (!isset($aReferences[1])) {
1✔
2695
                //    Single cell (or single column or row) in range
2696
                [$currentCol, $currentRow] = Coordinate::coordinateFromString($aReferences[0]);
1✔
2697
                /** @var mixed[][] $returnValue */
2698
                if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
1✔
2699
                    $returnValue[$currentRow][$currentCol] = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
1✔
2700
                } else {
2701
                    $returnValue[$currentRow][$currentCol] = null;
1✔
2702
                }
2703
            } else {
2704
                // Extract cell data for all cells in the range
2705
                foreach ($aReferences as $reference) {
1✔
2706
                    // Extract range
2707
                    [$currentCol, $currentRow] = Coordinate::coordinateFromString($reference);
1✔
2708
                    if ($worksheet !== null && $worksheet->cellExists($reference)) {
1✔
2709
                        $returnValue[$currentRow][$currentCol] = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
1✔
2710
                    } else {
2711
                        $returnValue[$currentRow][$currentCol] = null;
1✔
2712
                    }
2713
                }
2714
            }
2715
        }
2716

2717
        return $returnValue;
1✔
2718
    }
2719

2720
    /**
2721
     * Is a specific function implemented?
2722
     *
2723
     * @param string $function Function Name
2724
     */
2725
    public function isImplemented(string $function): bool
3✔
2726
    {
2727
        $function = strtoupper($function);
3✔
2728
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
3✔
2729
        $notImplemented = !isset($phpSpreadsheetFunctions[$function]) || (is_array($phpSpreadsheetFunctions[$function]['functionCall']) && $phpSpreadsheetFunctions[$function]['functionCall'][1] === 'DUMMY');
3✔
2730

2731
        return !$notImplemented;
3✔
2732
    }
2733

2734
    /**
2735
     * Get a list of implemented Excel function names.
2736
     *
2737
     * @return string[]
2738
     */
2739
    public function getImplementedFunctionNames(): array
2✔
2740
    {
2741
        $returnValue = [];
2✔
2742
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
2✔
2743
        foreach ($phpSpreadsheetFunctions as $functionName => $function) {
2✔
2744
            if ($this->isImplemented($functionName)) {
2✔
2745
                $returnValue[] = $functionName;
2✔
2746
            }
2747
        }
2748

2749
        return $returnValue;
2✔
2750
    }
2751

2752
    /**
2753
     * @param string[] $functionCall
2754
     * @param mixed[] $args
2755
     * @param mixed[] $emptyArguments
2756
     *
2757
     * @return mixed[]
2758
     */
2759
    private function addDefaultArgumentValues(array $functionCall, array $args, array $emptyArguments): array
11,806✔
2760
    {
2761
        $reflector = new ReflectionMethod($functionCall[0], $functionCall[1]);
11,806✔
2762
        $methodArguments = $reflector->getParameters();
11,806✔
2763

2764
        if (count($methodArguments) > 0) {
11,806✔
2765
            // Apply any defaults for empty argument values
2766
            foreach ($emptyArguments as $argumentId => $isArgumentEmpty) {
11,806✔
2767
                if ($isArgumentEmpty === true) {
11,750✔
2768
                    $reflectedArgumentId = count($args) - (int) $argumentId - 1;
148✔
2769
                    if (
2770
                        !array_key_exists($reflectedArgumentId, $methodArguments)
148✔
2771
                        || $methodArguments[$reflectedArgumentId]->isVariadic()
148✔
2772
                    ) {
2773
                        break;
13✔
2774
                    }
2775

2776
                    $args[$argumentId] = $this->getArgumentDefaultValue($methodArguments[$reflectedArgumentId]);
135✔
2777
                }
2778
            }
2779
        }
2780

2781
        return $args;
11,806✔
2782
    }
2783

2784
    private function getArgumentDefaultValue(ReflectionParameter $methodArgument): mixed
135✔
2785
    {
2786
        $defaultValue = null;
135✔
2787

2788
        if ($methodArgument->isDefaultValueAvailable()) {
135✔
2789
            $defaultValue = $methodArgument->getDefaultValue();
63✔
2790
            if ($methodArgument->isDefaultValueConstant()) {
63✔
2791
                $constantName = $methodArgument->getDefaultValueConstantName() ?? '';
2✔
2792
                // read constant value
2793
                if (str_contains($constantName, '::')) {
2✔
2794
                    [$className, $constantName] = explode('::', $constantName);
2✔
2795
                    /** @var class-string $className */
2796
                    $constantReflector = new ReflectionClassConstant($className, $constantName);
2✔
2797

2798
                    return $constantReflector->getValue();
2✔
2799
                }
2800

UNCOV
2801
                return constant($constantName);
×
2802
            }
2803
        }
2804

2805
        return $defaultValue;
134✔
2806
    }
2807

2808
    /**
2809
     * Add cell reference if needed while making sure that it is the last argument.
2810
     *
2811
     * @param mixed[] $args
2812
     * @param string|string[] $functionCall
2813
     *
2814
     * @return mixed[]
2815
     */
2816
    private function addCellReference(array $args, bool $passCellReference, array|string $functionCall, ?Cell $cell = null): array
11,824✔
2817
    {
2818
        if ($passCellReference) {
11,824✔
2819
            if (is_array($functionCall)) {
280✔
2820
                $className = $functionCall[0];
280✔
2821
                $methodName = $functionCall[1];
280✔
2822

2823
                $reflectionMethod = new ReflectionMethod($className, $methodName);
280✔
2824
                $argumentCount = count($reflectionMethod->getParameters());
280✔
2825
                while (count($args) < $argumentCount - 1) {
280✔
2826
                    $args[] = null;
57✔
2827
                }
2828
            }
2829

2830
            $args[] = $cell;
280✔
2831
        }
2832

2833
        return $args;
11,824✔
2834
    }
2835

2836
    private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksheet $cellWorksheet, Stack $stack, bool $ignoreScope = false): mixed
137✔
2837
    {
2838
        $definedNameScope = $namedRange->getScope();
137✔
2839
        if ($definedNameScope !== null && $definedNameScope !== $cellWorksheet && !$ignoreScope) {
137✔
2840
            // The defined name isn't in our current scope, so #REF
UNCOV
2841
            $result = ExcelError::REF();
×
UNCOV
2842
            $stack->push('Error', $result, $namedRange->getName());
×
2843

UNCOV
2844
            return $result;
×
2845
        }
2846

2847
        $definedNameValue = $namedRange->getValue();
137✔
2848
        $definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
137✔
2849
        if ($definedNameType === 'Range') {
137✔
2850
            if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
132✔
2851
                $matches2 = Preg::replace(
132✔
2852
                    ['/ +/', '/,/'],
132✔
2853
                    [' ∩ ', ' ∪ '],
132✔
2854
                    trim($matches[2])
132✔
2855
                );
132✔
2856
                $definedNameValue = $matches[1] . $matches2;
132✔
2857
            }
2858
        }
2859
        $definedNameWorksheet = $namedRange->getWorksheet();
137✔
2860

2861
        if ($definedNameValue[0] !== '=') {
137✔
2862
            $definedNameValue = '=' . $definedNameValue;
114✔
2863
        }
2864

2865
        $this->debugLog->writeDebugLog('Defined Name is a %s with a value of %s', $definedNameType, $definedNameValue);
137✔
2866

2867
        $originalCoordinate = $cell->getCoordinate();
137✔
2868
        $recursiveCalculationCell = ($definedNameType !== 'Formula' && $definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
137✔
2869
            ? $definedNameWorksheet->getCell('A1')
20✔
2870
            : $cell;
126✔
2871
        $recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();
137✔
2872

2873
        // Adjust relative references in ranges and formulae so that we execute the calculation for the correct rows and columns
2874
        $definedNameValue = ReferenceHelper::getInstance()
137✔
2875
            ->updateFormulaReferencesAnyWorksheet(
137✔
2876
                $definedNameValue,
137✔
2877
                Coordinate::columnIndexFromString(
137✔
2878
                    $cell->getColumn()
137✔
2879
                ) - 1,
137✔
2880
                $cell->getRow() - 1
137✔
2881
            );
137✔
2882

2883
        $this->debugLog->writeDebugLog('Value adjusted for relative references is %s', $definedNameValue);
137✔
2884

2885
        $recursiveCalculator = new self($this->spreadsheet);
137✔
2886
        $recursiveCalculator->getDebugLog()->setWriteDebugLog($this->getDebugLog()->getWriteDebugLog());
137✔
2887
        $recursiveCalculator->getDebugLog()->setEchoDebugLog($this->getDebugLog()->getEchoDebugLog());
137✔
2888
        $result = $recursiveCalculator->_calculateFormulaValue($definedNameValue, $recursiveCalculationCellAddress, $recursiveCalculationCell, true);
137✔
2889
        $cellWorksheet->getCell($originalCoordinate);
137✔
2890

2891
        if ($this->getDebugLog()->getWriteDebugLog()) {
137✔
UNCOV
2892
            $this->debugLog->mergeDebugLog(array_slice($recursiveCalculator->getDebugLog()->getLog(), 3));
×
UNCOV
2893
            $this->debugLog->writeDebugLog('Evaluation Result for Named %s %s is %s', $definedNameType, $namedRange->getName(), $this->showTypeDetails($result));
×
2894
        }
2895

2896
        $y = $namedRange->getWorksheet()?->getTitle();
137✔
2897
        $x = $namedRange->getLocalOnly();
137✔
2898
        if ($x && $y !== null) {
137✔
2899
            $stack->push('Defined Name', $result, "'$y'!" . $namedRange->getName());
20✔
2900
        } else {
2901
            $stack->push('Defined Name', $result, $namedRange->getName());
118✔
2902
        }
2903

2904
        return $result;
137✔
2905
    }
2906

2907
    public function setSuppressFormulaErrors(bool $suppressFormulaErrors): self
12✔
2908
    {
2909
        $this->suppressFormulaErrors = $suppressFormulaErrors;
12✔
2910

2911
        return $this;
12✔
2912
    }
2913

2914
    public function getSuppressFormulaErrors(): bool
14✔
2915
    {
2916
        return $this->suppressFormulaErrors;
14✔
2917
    }
2918

2919
    public static function boolToString(mixed $operand1): mixed
33✔
2920
    {
2921
        if (is_bool($operand1)) {
33✔
2922
            $operand1 = ($operand1) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
1✔
2923
        } elseif ($operand1 === null) {
33✔
2924
            $operand1 = '';
1✔
2925
        }
2926

2927
        return $operand1;
33✔
2928
    }
2929

2930
    private static function isNumericOrBool(mixed $operand): bool
42✔
2931
    {
2932
        return is_numeric($operand) || is_bool($operand);
42✔
2933
    }
2934

2935
    private static function makeError(mixed $operand = ''): string
3✔
2936
    {
2937
        return (is_string($operand) && Information\ErrorValue::isError($operand)) ? $operand : ExcelError::VALUE();
3✔
2938
    }
2939

2940
    private static function swapOperands(Stack $stack, string $opCharacter): bool
1,900✔
2941
    {
2942
        $retVal = false;
1,900✔
2943
        if ($stack->count() > 0) {
1,900✔
2944
            $o2 = $stack->last();
1,412✔
2945
            if ($o2) {
1,412✔
2946
                /** @var array{value: string} $o2 */
2947
                if (isset(self::CALCULATION_OPERATORS[$o2['value']])) {
1,412✔
2948
                    $retVal = (self::OPERATOR_PRECEDENCE[$opCharacter] ?? 0) <= self::OPERATOR_PRECEDENCE[$o2['value']];
125✔
2949
                }
2950
            }
2951
        }
2952

2953
        return $retVal;
1,900✔
2954
    }
2955

2956
    public function getSpreadsheet(): ?Spreadsheet
2✔
2957
    {
2958
        return $this->spreadsheet;
2✔
2959
    }
2960
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc