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

PHPOffice / PhpSpreadsheet / 22984986766

12 Mar 2026 03:16AM UTC coverage: 96.894% (-0.01%) from 96.904%
22984986766

Pull #4835

github

web-flow
Merge 5aeca473e into a1dacfdf7
Pull Request #4835: Confusion Checking for Union Arguments

7 of 12 new or added lines in 1 file covered. (58.33%)

63 existing lines in 1 file now uncovered.

47698 of 49227 relevant lines covered (96.89%)

383.98 hits per line

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

93.0
/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\Exception as SpreadsheetException;
18
use PhpOffice\PhpSpreadsheet\NamedRange;
19
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
20
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
21
use PhpOffice\PhpSpreadsheet\Spreadsheet;
22
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
23
use ReflectionClassConstant;
24
use ReflectionMethod;
25
use ReflectionParameter;
26
use Throwable;
27
use TypeError;
28

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

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

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

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

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

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

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

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

93
    private BranchPruner $branchPruner;
94

95
    protected bool $branchPruningEnabled = true;
96

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

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

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

124
    private bool $suppressFormulaErrors = false;
125

126
    private bool $processingAnchorArray = false;
127

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

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

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

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

148
    private string $cyclicFormulaCell = '';
149

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

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

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

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

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

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

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

215
        if (!self::$instance) {
4,710✔
216
            self::$instance = new self();
16✔
217
        }
218

219
        return self::$instance;
4,710✔
220
    }
221

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

233
        return null;
115✔
234
    }
235

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

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

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

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

278
            return true;
599✔
279
        }
280

281
        return false;
1✔
282
    }
283

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

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

310
            return true;
430✔
311
        }
312

313
        return false;
2✔
314
    }
315

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

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

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

342
        return $this;
10✔
343
    }
344

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

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

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

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

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

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

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

400
        return $this;
22✔
401
    }
402

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

408
    public function disableBranchPruning(): void
12✔
409
    {
410
        $this->setBranchPruningEnabled(false);
12✔
411
    }
412

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

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

432
        return $value;
9,487✔
433
    }
434

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

449
        return $value;
11,538✔
450
    }
451

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

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

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

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

493
        $cellAddressAttempted = false;
8,501✔
494
        $cellAddress = null;
8,501✔
495

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

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

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

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

550
        return $result;
8,262✔
551
    }
552

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

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

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

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

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

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

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

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

630
            $cellValue = $this->calculationCache[$cellReference];
376✔
631

632
            return true;
376✔
633
        }
634

635
        return false;
8,654✔
636
    }
637

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

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

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

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

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

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

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

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

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

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

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

711
        $cellValue = $this->processTokenStack($this->internalParseFormula($formula, $cell), $cellID, $cell);
12,385✔
712
        $this->cyclicReferenceStack->pop();
12,146✔
713

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

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

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

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

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

782
        return [$matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns];
71✔
783
    }
784

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

807
        return [$matrixRows, $matrixColumns];
113✔
808
    }
809

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

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

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

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

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

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

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

937
        return Functions::flattenSingleValue($value);
12,122✔
938
    }
939

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

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

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

976
        return null;
12,138✔
977
    }
978

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

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

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

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

1031
        return $formula;
12,535✔
1032
    }
1033

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

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

1058
    /** @param array<?string> $matches */
1059
    private function unionForComma(array $matches): string
5✔
1060
    {
1061
        $matches5 = (string) $matches[5];
5✔
1062
        if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
5✔
NEW
1063
            if ($this->spreadsheet !== null) {
×
NEW
1064
                if ($this->spreadsheet->getSheetByName($matches5) === null) {
×
NEW
1065
                    $matches0 = (string) $matches[0];
×
NEW
1066
                    $this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
×
1067

NEW
1068
                    return $matches0;
×
1069
                }
1070
            }
1071
        }
1072
        $matches1 = (string) $matches[1];
5✔
1073
        $matches2 = (string) $matches[2];
5✔
1074

1075
        return $matches1 . str_replace(',', '∪', $matches2);
5✔
1076
    }
1077

1078
    private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
1079
        = '(?:'
1080
        . self::CALCULATION_REGEXP_CELLREF // cell address
1081
        . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
1082
        . '|' . self::CALCULATION_REGEXP_DEFINEDNAME
1083
        . ')'
1084
        ;
1085

1086
    public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
1087
        . '([(]' // open paren
1088
        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1089
        . '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
1090
        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1091
        . ')+' // one or more occurrences
1092
        . '\s*[)])/i'; // optional whitespace, end paren
1093

1094
    /**
1095
     * @return array<int, mixed>|false
1096
     */
1097
    private function internalParseFormula(string $formula, ?Cell $cell = null): bool|array
12,536✔
1098
    {
1099
        if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
12,536✔
UNCOV
1100
            return false;
×
1101
        }
1102

1103
        $oldFormula = $formula;
12,535✔
1104
        $formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, $this->unionForComma(...), $formula);
12,535✔
1105
        if ($oldFormula !== $formula) {
12,535✔
1106
            $this->debugLog->writeDebugLog('Reformulated as %s', $formula);
5✔
1107
        }
1108
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,535✔
1109

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

1114
        $regexpMatchString = '/^((?<string>' . self::CALCULATION_REGEXP_STRING
12,535✔
1115
                                . ')|(?<function>' . self::CALCULATION_REGEXP_FUNCTION
12,535✔
1116
                                . ')|(?<cellRef>' . self::CALCULATION_REGEXP_CELLREF
12,535✔
1117
                                . ')|(?<colRange>' . self::CALCULATION_REGEXP_COLUMN_RANGE
12,535✔
1118
                                . ')|(?<rowRange>' . self::CALCULATION_REGEXP_ROW_RANGE
12,535✔
1119
                                . ')|(?<number>' . self::CALCULATION_REGEXP_NUMBER
12,535✔
1120
                                . ')|(?<openBrace>' . self::CALCULATION_REGEXP_OPENBRACE
12,535✔
1121
                                . ')|(?<structuredReference>' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE
12,535✔
1122
                                . ')|(?<definedName>' . self::CALCULATION_REGEXP_DEFINEDNAME
12,535✔
1123
                                . ')|(?<error>' . self::CALCULATION_REGEXP_ERROR
12,535✔
1124
                                . '))/sui';
12,535✔
1125

1126
        //    Start with initialisation
1127
        $index = 0;
12,535✔
1128
        $stack = new Stack($this->branchPruner);
12,535✔
1129
        $output = [];
12,535✔
1130
        $expectingOperator = false; //    We use this test in syntax-checking the expression to determine when a
12,535✔
1131
        //        - is a negation or + is a positive operator rather than an operation
1132
        $expectingOperand = false; //    We use this test in syntax-checking the expression to determine whether an operand
12,535✔
1133
        //        should be null in a function call
1134

1135
        //    The guts of the lexical parser
1136
        //    Loop through the formula extracting each operator and operand in turn
1137
        while (true) {
12,535✔
1138
            // Branch pruning: we adapt the output item to the context (it will
1139
            // be used to limit its computation)
1140
            $this->branchPruner->initialiseForLoop();
12,535✔
1141

1142
            $opCharacter = $formula[$index]; //    Get the first character of the value at the current index position
12,535✔
1143
            if ($opCharacter === "\xe2") { // intersection or union
12,535✔
1144
                $opCharacter .= $formula[++$index];
8✔
1145
                $opCharacter .= $formula[++$index];
8✔
1146
            }
1147

1148
            // Check for two-character operators (e.g. >=, <=, <>)
1149
            if ((isset(self::COMPARISON_OPERATORS[$opCharacter])) && (strlen($formula) > $index) && isset($formula[$index + 1], self::COMPARISON_OPERATORS[$formula[$index + 1]])) {
12,535✔
1150
                $opCharacter .= $formula[++$index];
85✔
1151
            }
1152
            //    Find out if we're currently at the beginning of a number, variable, cell/row/column reference,
1153
            //         function, defined name, structured reference, parenthesis, error or operand
1154
            $isOperandOrFunction = (bool) preg_match($regexpMatchString, substr($formula, $index), $match);
12,535✔
1155

1156
            $expectingOperatorCopy = $expectingOperator;
12,535✔
1157
            if ($opCharacter === '-' && !$expectingOperator) {                //    Is it a negation instead of a minus?
12,535✔
1158
                //    Put a negation on the stack
1159
                $stack->push('Unary Operator', '~');
1,174✔
1160
                ++$index; //        and drop the negation symbol
1,174✔
1161
            } elseif ($opCharacter === '%' && $expectingOperator) {
12,535✔
1162
                //    Put a percentage on the stack
1163
                $stack->push('Unary Operator', '%');
11✔
1164
                ++$index;
11✔
1165
            } elseif ($opCharacter === '+' && !$expectingOperator) {            //    Positive (unary plus rather than binary operator plus) can be discarded?
12,535✔
1166
                ++$index; //    Drop the redundant plus symbol
7✔
1167
            } elseif ((($opCharacter === '~') /*|| ($opCharacter === '∩') || ($opCharacter === '∪')*/) && (!$isOperandOrFunction)) {
12,535✔
1168
                //    We have to explicitly deny a tilde, union or intersect because they are legal
UNCOV
1169
                return $this->raiseFormulaError("Formula Error: Illegal character '~'"); //        on the stack but not in the input expression
×
1170
            } elseif ((isset(self::CALCULATION_OPERATORS[$opCharacter]) || $isOperandOrFunction) && $expectingOperator) {    //    Are we putting an operator on the stack?
12,535✔
1171
                while (self::swapOperands($stack, $opCharacter)) {
1,916✔
1172
                    $output[] = $stack->pop(); //    Swap operands and higher precedence operators from the stack to the output
106✔
1173
                }
1174

1175
                //    Finally put our current operator onto the stack
1176
                $stack->push('Binary Operator', $opCharacter);
1,916✔
1177

1178
                ++$index;
1,916✔
1179
                $expectingOperator = false;
1,916✔
1180
            } elseif ($opCharacter === ')' && $expectingOperator) { //    Are we expecting to close a parenthesis?
12,535✔
1181
                $expectingOperand = false;
12,139✔
1182
                while (($o2 = $stack->pop()) && $o2['value'] !== '(') { //    Pop off the stack back to the last (
12,139✔
1183
                    $output[] = $o2;
1,438✔
1184
                }
1185
                $d = $stack->last(2);
12,139✔
1186

1187
                // Branch pruning we decrease the depth whether is it a function
1188
                // call or a parenthesis
1189
                $this->branchPruner->decrementDepth();
12,139✔
1190

1191
                if (is_array($d) && preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($d['value']), $matches)) {
12,139✔
1192
                    //    Did this parenthesis just close a function?
1193
                    try {
1194
                        $this->branchPruner->closingBrace($d['value']);
12,133✔
1195
                    } catch (Exception $e) {
4✔
1196
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
4✔
1197
                    }
1198

1199
                    $functionName = $matches[1]; //    Get the function name
12,133✔
1200
                    $d = $stack->pop();
12,133✔
1201
                    $argumentCount = $d['value'] ?? 0; //    See how many arguments there were (argument count is the next value stored on the stack)
12,133✔
1202
                    $output[] = $d; //    Dump the argument count on the output
12,133✔
1203
                    $output[] = $stack->pop(); //    Pop the function and push onto the output
12,133✔
1204
                    if (isset(self::$controlFunctions[$functionName])) {
12,133✔
1205
                        $expectedArgumentCount = self::$controlFunctions[$functionName]['argumentCount'];
845✔
1206
                    } elseif (isset($phpSpreadsheetFunctions[$functionName])) {
12,130✔
1207
                        $expectedArgumentCount = $phpSpreadsheetFunctions[$functionName]['argumentCount'];
12,130✔
1208
                    } else {    // did we somehow push a non-function on the stack? this should never happen
UNCOV
1209
                        return $this->raiseFormulaError('Formula Error: Internal error, non-function on stack');
×
1210
                    }
1211
                    //    Check the argument count
1212
                    $argumentCountError = false;
12,133✔
1213
                    $expectedArgumentCountString = null;
12,133✔
1214
                    if (is_numeric($expectedArgumentCount)) {
12,133✔
1215
                        if ($expectedArgumentCount < 0) {
6,209✔
1216
                            if ($argumentCount > abs($expectedArgumentCount + 0)) {
46✔
1217
                                $argumentCountError = true;
×
UNCOV
1218
                                $expectedArgumentCountString = 'no more than ' . abs($expectedArgumentCount + 0);
×
1219
                            }
1220
                        } else {
1221
                            if ($argumentCount != $expectedArgumentCount) {
6,166✔
1222
                                $argumentCountError = true;
146✔
1223
                                $expectedArgumentCountString = $expectedArgumentCount;
146✔
1224
                            }
1225
                        }
1226
                    } elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
6,704✔
1227
                        if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
6,215✔
1228
                            $argMatch = ['', '', '', ''];
1✔
1229
                        }
1230
                        switch ($argMatch[2]) {
6,215✔
1231
                            case '+':
6,215✔
1232
                                if ($argumentCount < $argMatch[1]) {
1,210✔
1233
                                    $argumentCountError = true;
29✔
1234
                                    $expectedArgumentCountString = $argMatch[1] . ' or more ';
29✔
1235
                                }
1236

1237
                                break;
1,210✔
1238
                            case '-':
5,173✔
1239
                                if (($argumentCount < $argMatch[1]) || ($argumentCount > $argMatch[3])) {
931✔
1240
                                    $argumentCountError = true;
15✔
1241
                                    $expectedArgumentCountString = 'between ' . $argMatch[1] . ' and ' . $argMatch[3];
15✔
1242
                                }
1243

1244
                                break;
931✔
1245
                            case ',':
4,284✔
1246
                                if (($argumentCount != $argMatch[1]) && ($argumentCount != $argMatch[3])) {
4,284✔
1247
                                    $argumentCountError = true;
39✔
1248
                                    $expectedArgumentCountString = 'either ' . $argMatch[1] . ' or ' . $argMatch[3];
39✔
1249
                                }
1250

1251
                                break;
4,284✔
1252
                        }
1253
                    }
1254
                    if ($argumentCountError) {
12,133✔
1255
                        /** @var int $argumentCount */
1256
                        return $this->raiseFormulaError("Formula Error: Wrong number of arguments for $functionName() function: $argumentCount given, " . $expectedArgumentCountString . ' expected');
229✔
1257
                    }
1258
                }
1259
                ++$index;
11,913✔
1260
            } elseif ($opCharacter === ',') { // Is this the separator for function arguments?
12,535✔
1261
                try {
1262
                    $this->branchPruner->argumentSeparator();
8,197✔
UNCOV
1263
                } catch (Exception $e) {
×
UNCOV
1264
                    return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1265
                }
1266

1267
                while (($o2 = $stack->pop()) && $o2['value'] !== '(') {        //    Pop off the stack back to the last (
8,197✔
1268
                    $output[] = $o2; // pop the argument expression stuff and push onto the output
1,515✔
1269
                }
1270
                //    If we've a comma when we're expecting an operand, then what we actually have is a null operand;
1271
                //        so push a null onto the stack
1272
                if (($expectingOperand) || (!$expectingOperator)) {
8,197✔
1273
                    $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
120✔
1274
                }
1275
                // make sure there was a function
1276
                $d = $stack->last(2);
8,197✔
1277
                /** @var string */
1278
                $temp = $d['value'] ?? '';
8,197✔
1279
                if (!preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $temp, $matches)) {
8,197✔
1280
                    // Can we inject a dummy function at this point so that the braces at least have some context
1281
                    //     because at least the braces are paired up (at this stage in the formula)
1282
                    // MS Excel allows this if the content is cell references; but doesn't allow actual values,
1283
                    //    but at this point, we can't differentiate (so allow both)
1284
                    //return $this->raiseFormulaError('Formula Error: Unexpected ,');
1285

1286
                    $stack->push('Binary Operator', '∪');
1✔
1287

1288
                    ++$index;
1✔
1289
                    $expectingOperator = false;
1✔
1290

1291
                    continue;
1✔
1292
                }
1293

1294
                /** @var array<string, int> $d */
1295
                $d = $stack->pop();
8,196✔
1296
                ++$d['value']; // increment the argument count
8,196✔
1297

1298
                $stack->pushStackItem($d);
8,196✔
1299
                $stack->push('Brace', '('); // put the ( back on, we'll need to pop back to it again
8,196✔
1300

1301
                $expectingOperator = false;
8,196✔
1302
                $expectingOperand = true;
8,196✔
1303
                ++$index;
8,196✔
1304
            } elseif ($opCharacter === '(' && !$expectingOperator) {
12,535✔
1305
                // Branch pruning: we go deeper
1306
                $this->branchPruner->incrementDepth();
43✔
1307
                $stack->push('Brace', '(', null);
43✔
1308
                ++$index;
43✔
1309
            } elseif ($isOperandOrFunction && !$expectingOperatorCopy) {
12,535✔
1310
                // do we now have a function/variable/number?
1311
                $expectingOperator = true;
12,531✔
1312
                $expectingOperand = false;
12,531✔
1313
                $val = $match[1] ?? ''; //* @phpstan-ignore-line
12,531✔
1314
                $length = strlen($val);
12,531✔
1315

1316
                if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
12,531✔
1317
                    // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
1318
                    $val = Preg::replace('/\s/u', '', $val);
12,140✔
1319
                    if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) {    // it's a function
12,140✔
1320
                        $valToUpper = strtoupper($val);
12,138✔
1321
                    } else {
1322
                        $valToUpper = 'NAME.ERROR(';
6✔
1323
                    }
1324
                    // here $matches[1] will contain values like "IF"
1325
                    // and $val "IF("
1326

1327
                    $this->branchPruner->functionCall($valToUpper);
12,140✔
1328

1329
                    $stack->push('Function', $valToUpper);
12,140✔
1330
                    // tests if the function is closed right after opening
1331
                    $ax = preg_match('/^\s*\)/u', substr($formula, $index + $length));
12,140✔
1332
                    if ($ax) {
12,140✔
1333
                        $stack->push('Operand Count for Function ' . $valToUpper . ')', 0);
331✔
1334
                        $expectingOperator = true;
331✔
1335
                    } else {
1336
                        $stack->push('Operand Count for Function ' . $valToUpper . ')', 1);
11,955✔
1337
                        $expectingOperator = false;
11,955✔
1338
                    }
1339
                    $stack->push('Brace', '(');
12,140✔
1340
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $val, $matches)) {
12,322✔
1341
                    //    Watch for this case-change when modifying to allow cell references in different worksheets...
1342
                    //    Should only be applied to the actual cell column, not the worksheet name
1343
                    //    If the last entry on the stack was a : operator, then we have a cell range reference
1344
                    $testPrevOp = $stack->last(1);
7,345✔
1345
                    if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
7,345✔
1346
                        //    If we have a worksheet reference, then we're playing with a 3D reference
1347
                        if ($matches[2] === '') {
1,312✔
1348
                            //    Otherwise, we 'inherit' the worksheet reference from the start cell reference
1349
                            //    The start of the cell range reference should be the last entry in $output
1350
                            $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
1,308✔
1351
                            if ($rangeStartCellRef === ':') {
1,308✔
1352
                                // Do we have chained range operators?
1353
                                $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
5✔
1354
                            }
1355
                            /** @var string $rangeStartCellRef */
1356
                            preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
1,308✔
1357
                            if (array_key_exists(2, $rangeStartMatches)) {
1,308✔
1358
                                if ($rangeStartMatches[2] > '') {
1,303✔
1359
                                    $val = $rangeStartMatches[2] . '!' . $val;
1,283✔
1360
                                }
1361
                            } else {
1362
                                $val = ExcelError::REF();
5✔
1363
                            }
1364
                        } else {
1365
                            $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
4✔
1366
                            if ($rangeStartCellRef === ':') {
4✔
1367
                                // Do we have chained range operators?
UNCOV
1368
                                $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
×
1369
                            }
1370
                            /** @var string $rangeStartCellRef */
1371
                            preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
4✔
1372
                            if (isset($rangeStartMatches[2]) && $rangeStartMatches[2] !== $matches[2]) {
4✔
1373
                                return $this->raiseFormulaError('3D Range references are not yet supported');
2✔
1374
                            }
1375
                        }
1376
                    } elseif (!str_contains($val, '!') && $pCellParent !== null) {
7,340✔
1377
                        $worksheet = $pCellParent->getTitle();
7,116✔
1378
                        $val = "'{$worksheet}'!{$val}";
7,116✔
1379
                    }
1380
                    // unescape any apostrophes or double quotes in worksheet name
1381
                    $val = str_replace(["''", '""'], ["'", '"'], $val);
7,345✔
1382
                    $outputItem = $stack->getStackItem('Cell Reference', $val, $val);
7,345✔
1383

1384
                    $output[] = $outputItem;
7,345✔
1385
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '$/miu', $val, $matches)) {
6,506✔
1386
                    try {
1387
                        $structuredReference = Operands\StructuredReference::fromParser($formula, $index, $matches);
76✔
UNCOV
1388
                    } catch (Exception $e) {
×
UNCOV
1389
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1390
                    }
1391

1392
                    $val = $structuredReference->value();
76✔
1393
                    $length = strlen($val);
76✔
1394
                    $outputItem = $stack->getStackItem(Operands\StructuredReference::NAME, $structuredReference, null);
76✔
1395

1396
                    $output[] = $outputItem;
76✔
1397
                    $expectingOperator = true;
76✔
1398
                } else {
1399
                    // it's a variable, constant, string, number or boolean
1400
                    $localeConstant = false;
6,439✔
1401
                    $stackItemType = 'Value';
6,439✔
1402
                    $stackItemReference = null;
6,439✔
1403

1404
                    //    If the last entry on the stack was a : operator, then we may have a row or column range reference
1405
                    $testPrevOp = $stack->last(1);
6,439✔
1406
                    if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
6,439✔
1407
                        $stackItemType = 'Cell Reference';
39✔
1408

1409
                        if (
1410
                            !is_numeric($val)
39✔
1411
                            && ((ctype_alpha($val) === false || strlen($val) > 3))
39✔
1412
                            && (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/mui', $val) !== false)
39✔
1413
                            && ($this->spreadsheet === null || $this->spreadsheet->getNamedRange($val) !== null)
39✔
1414
                        ) {
1415
                            $namedRange = ($this->spreadsheet === null) ? null : $this->spreadsheet->getNamedRange($val);
10✔
1416
                            if ($namedRange !== null) {
10✔
1417
                                $stackItemType = 'Defined Name';
4✔
1418
                                $address = str_replace('$', '', $namedRange->getValue());
4✔
1419
                                $stackItemReference = $val;
4✔
1420
                                if (str_contains($address, ':')) {
4✔
1421
                                    // We'll need to manipulate the stack for an actual named range rather than a named cell
1422
                                    $fromTo = explode(':', $address);
3✔
1423
                                    $to = array_pop($fromTo);
3✔
1424
                                    foreach ($fromTo as $from) {
3✔
1425
                                        $output[] = $stack->getStackItem($stackItemType, $from, $stackItemReference);
3✔
1426
                                        $output[] = $stack->getStackItem('Binary Operator', ':');
3✔
1427
                                    }
1428
                                    $address = $to;
3✔
1429
                                }
1430
                                $val = $address;
4✔
1431
                            }
1432
                        } elseif ($val === ExcelError::REF()) {
35✔
1433
                            $stackItemReference = $val;
3✔
1434
                        } else {
1435
                            /** @var non-empty-string $startRowColRef */
1436
                            $startRowColRef = $output[count($output) - 1]['value'] ?? '';
32✔
1437
                            [$rangeWS1, $startRowColRef] = Worksheet::extractSheetTitle($startRowColRef, true);
32✔
1438
                            $rangeSheetRef = $rangeWS1;
32✔
1439
                            if ($rangeWS1 !== '') {
32✔
1440
                                $rangeWS1 .= '!';
19✔
1441
                            }
1442
                            if (str_starts_with($rangeSheetRef, "'")) {
32✔
1443
                                $rangeSheetRef = Worksheet::unApostrophizeTitle($rangeSheetRef);
18✔
1444
                            }
1445
                            [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true);
32✔
1446
                            if ($rangeWS2 !== '') {
32✔
UNCOV
1447
                                $rangeWS2 .= '!';
×
1448
                            } else {
1449
                                $rangeWS2 = $rangeWS1;
32✔
1450
                            }
1451

1452
                            $refSheet = $pCellParent;
32✔
1453
                            if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) {
32✔
1454
                                $refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
4✔
1455
                            }
1456

1457
                            if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
32✔
1458
                                //    Row range
1459
                                $stackItemType = 'Row Reference';
10✔
1460
                                $valx = $val;
10✔
1461
                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataColumn($valx) : AddressRange::MAX_COLUMN; //    Max 16,384 columns for Excel2007
10✔
1462
                                $val = "{$rangeWS2}{$endRowColRef}{$val}";
10✔
1463
                            } elseif (ctype_alpha($val) && strlen($val) <= 3) {
22✔
1464
                                //    Column range
1465
                                $stackItemType = 'Column Reference';
16✔
1466
                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; //    Max 1,048,576 rows for Excel2007
16✔
1467
                                $val = "{$rangeWS2}{$val}{$endRowColRef}";
16✔
1468
                            }
1469
                            $stackItemReference = $val;
32✔
1470
                        }
1471
                    } elseif ($opCharacter === self::FORMULA_STRING_QUOTE) {
6,434✔
1472
                        //    UnEscape any quotes within the string
1473
                        $val = self::wrapResult(str_replace('""', self::FORMULA_STRING_QUOTE, StringHelper::convertToString(self::unwrapResult($val))));
2,875✔
1474
                    } elseif (isset(self::EXCEL_CONSTANTS[trim(strtoupper($val))])) {
4,781✔
1475
                        $stackItemType = 'Constant';
572✔
1476
                        $excelConstant = trim(strtoupper($val));
572✔
1477
                        $val = self::EXCEL_CONSTANTS[$excelConstant];
572✔
1478
                        $stackItemReference = $excelConstant;
572✔
1479
                    } elseif (($localeConstant = array_search(trim(strtoupper($val)), self::$localeBoolean)) !== false) {
4,486✔
1480
                        $stackItemType = 'Constant';
39✔
1481
                        $val = self::EXCEL_CONSTANTS[$localeConstant];
39✔
1482
                        $stackItemReference = $localeConstant;
39✔
1483
                    } elseif (
1484
                        preg_match('/^' . self::CALCULATION_REGEXP_ROW_RANGE . '/miu', substr($formula, $index), $rowRangeReference)
4,467✔
1485
                    ) {
1486
                        $val = $rowRangeReference[1];
10✔
1487
                        $length = strlen($rowRangeReference[1]);
10✔
1488
                        $stackItemType = 'Row Reference';
10✔
1489
                        // unescape any apostrophes or double quotes in worksheet name
1490
                        $val = str_replace(["''", '""'], ["'", '"'], $val);
10✔
1491
                        $column = 'A';
10✔
1492
                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
10✔
UNCOV
1493
                            $column = $pCellParent->getHighestDataColumn($val);
×
1494
                        }
1495
                        $val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
10✔
1496
                        $stackItemReference = $val;
10✔
1497
                    } elseif (
1498
                        preg_match('/^' . self::CALCULATION_REGEXP_COLUMN_RANGE . '/miu', substr($formula, $index), $columnRangeReference)
4,460✔
1499
                    ) {
1500
                        $val = $columnRangeReference[1];
16✔
1501
                        $length = strlen($val);
16✔
1502
                        $stackItemType = 'Column Reference';
16✔
1503
                        // unescape any apostrophes or double quotes in worksheet name
1504
                        $val = str_replace(["''", '""'], ["'", '"'], $val);
16✔
1505
                        $row = '1';
16✔
1506
                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
16✔
UNCOV
1507
                            $row = $pCellParent->getHighestDataRow($val);
×
1508
                        }
1509
                        $val = "{$val}{$row}";
16✔
1510
                        $stackItemReference = $val;
16✔
1511
                    } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', $val, $match)) {
4,444✔
1512
                        $stackItemType = 'Defined Name';
168✔
1513
                        $stackItemReference = $val;
168✔
1514
                    } elseif (is_numeric($val)) {
4,308✔
1515
                        if ((str_contains((string) $val, '.')) || (stripos((string) $val, 'e') !== false) || ($val > PHP_INT_MAX) || ($val < -PHP_INT_MAX)) {
4,302✔
1516
                            $val = (float) $val;
1,696✔
1517
                        } else {
1518
                            $val = (int) $val;
3,536✔
1519
                        }
1520
                    }
1521

1522
                    $details = $stack->getStackItem($stackItemType, $val, $stackItemReference);
6,439✔
1523
                    if ($localeConstant) {
6,439✔
1524
                        $details['localeValue'] = $localeConstant;
39✔
1525
                    }
1526
                    $output[] = $details;
6,439✔
1527
                }
1528
                $index += $length;
12,531✔
1529
            } elseif ($opCharacter === '$') { // absolute row or column range
96✔
1530
                ++$index;
6✔
1531
            } elseif ($opCharacter === ')') { // miscellaneous error checking
90✔
1532
                if ($expectingOperand) {
83✔
1533
                    $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
83✔
1534
                    $expectingOperand = false;
83✔
1535
                    $expectingOperator = true;
83✔
1536
                } else {
UNCOV
1537
                    return $this->raiseFormulaError("Formula Error: Unexpected ')'");
×
1538
                }
1539
            } elseif (isset(self::CALCULATION_OPERATORS[$opCharacter]) && !$expectingOperator) {
7✔
UNCOV
1540
                return $this->raiseFormulaError("Formula Error: Unexpected operator '$opCharacter'");
×
1541
            } else {    // I don't even want to know what you did to get here
1542
                return $this->raiseFormulaError('Formula Error: An unexpected error occurred');
7✔
1543
            }
1544
            //    Test for end of formula string
1545
            if ($index == strlen($formula)) {
12,531✔
1546
                //    Did we end with an operator?.
1547
                //    Only valid for the % unary operator
1548
                if ((isset(self::CALCULATION_OPERATORS[$opCharacter])) && ($opCharacter != '%')) {
12,300✔
1549
                    return $this->raiseFormulaError("Formula Error: Operator '$opCharacter' has no operands");
1✔
1550
                }
1551

1552
                break;
12,299✔
1553
            }
1554
            //    Ignore white space
1555
            while (($formula[$index] === "\n") || ($formula[$index] === "\r")) {
12,498✔
UNCOV
1556
                ++$index;
×
1557
            }
1558

1559
            if ($formula[$index] === ' ') {
12,498✔
1560
                while ($formula[$index] === ' ') {
2,134✔
1561
                    ++$index;
2,134✔
1562
                }
1563

1564
                //    If we're expecting an operator, but only have a space between the previous and next operands (and both are
1565
                //        Cell References, Defined Names or Structured References) then we have an INTERSECTION operator
1566
                $countOutputMinus1 = count($output) - 1;
2,134✔
1567
                if (
1568
                    ($expectingOperator)
2,134✔
1569
                    && array_key_exists($countOutputMinus1, $output)
2,134✔
1570
                    && is_array($output[$countOutputMinus1])
2,134✔
1571
                    && array_key_exists('type', $output[$countOutputMinus1])
2,134✔
1572
                    && (
1573
                        (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '.*/miu', substr($formula, $index), $match))
2,134✔
1574
                            && ($output[$countOutputMinus1]['type'] === 'Cell Reference')
2,134✔
1575
                        || (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', substr($formula, $index), $match))
2,134✔
1576
                            && ($output[$countOutputMinus1]['type'] === 'Defined Name' || $output[$countOutputMinus1]['type'] === 'Value')
2,134✔
1577
                        || (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '.*/miu', substr($formula, $index), $match))
2,134✔
1578
                            && ($output[$countOutputMinus1]['type'] === Operands\StructuredReference::NAME || $output[$countOutputMinus1]['type'] === 'Value')
2,134✔
1579
                    )
1580
                ) {
1581
                    while (self::swapOperands($stack, $opCharacter)) {
21✔
1582
                        $output[] = $stack->pop(); //    Swap operands and higher precedence operators from the stack to the output
14✔
1583
                    }
1584
                    $stack->push('Binary Operator', '∩'); //    Put an Intersect Operator on the stack
21✔
1585
                    $expectingOperator = false;
21✔
1586
                }
1587
            }
1588
        }
1589

1590
        while (($op = $stack->pop()) !== null) {
12,299✔
1591
            // pop everything off the stack and push onto output
1592
            if ($op['value'] == '(') {
773✔
1593
                return $this->raiseFormulaError("Formula Error: Expecting ')'"); // if there are any opening braces on the stack, then braces were unbalanced
5✔
1594
            }
1595
            $output[] = $op;
770✔
1596
        }
1597

1598
        return $output;
12,296✔
1599
    }
1600

1601
    /** @param mixed[] $operandData */
1602
    private static function dataTestReference(array &$operandData): mixed
1,792✔
1603
    {
1604
        $operand = $operandData['value'];
1,792✔
1605
        if (($operandData['reference'] === null) && (is_array($operand))) {
1,792✔
1606
            $rKeys = array_keys($operand);
47✔
1607
            $rowKey = array_shift($rKeys);
47✔
1608
            if (is_array($operand[$rowKey]) === false) {
47✔
1609
                $operandData['value'] = $operand[$rowKey];
7✔
1610

1611
                return $operand[$rowKey];
7✔
1612
            }
1613

1614
            $cKeys = array_keys(array_keys($operand[$rowKey]));
44✔
1615
            $colKey = array_shift($cKeys);
44✔
1616
            if (ctype_upper("$colKey")) {
44✔
UNCOV
1617
                $operandData['reference'] = $colKey . $rowKey;
×
1618
            }
1619
        }
1620

1621
        return $operand;
1,792✔
1622
    }
1623

1624
    private static int $matchIndex8 = 8;
1625

1626
    private static int $matchIndex9 = 9;
1627

1628
    private static int $matchIndex10 = 10;
1629

1630
    /**
1631
     * @param array<mixed>|false $tokens
1632
     *
1633
     * @return array<int, mixed>|false|string
1634
     */
1635
    private function processTokenStack(false|array $tokens, ?string $cellID = null, ?Cell $cell = null)
12,154✔
1636
    {
1637
        if ($tokens === false) {
12,154✔
1638
            return false;
2✔
1639
        }
1640
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,153✔
1641

1642
        //    If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent cell collection),
1643
        //        so we store the parent cell collection so that we can re-attach it when necessary
1644
        $pCellWorksheet = ($cell !== null) ? $cell->getWorksheet() : null;
12,153✔
1645
        $originalCoordinate = $cell?->getCoordinate();
12,153✔
1646
        $pCellParent = ($cell !== null) ? $cell->getParent() : null;
12,153✔
1647
        $stack = new Stack($this->branchPruner);
12,153✔
1648

1649
        // Stores branches that have been pruned
1650
        $fakedForBranchPruning = [];
12,153✔
1651
        // help us to know when pruning ['branchTestId' => true/false]
1652
        $branchStore = [];
12,153✔
1653
        //    Loop through each token in turn
1654
        foreach ($tokens as $tokenIdx => $tokenData) {
12,153✔
1655
            /** @var mixed[] $tokenData */
1656
            $this->processingAnchorArray = false;
12,153✔
1657
            if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line
12,153✔
1658
                $this->processingAnchorArray = true;
6✔
1659
            }
1660
            $token = $tokenData['value'];
12,153✔
1661
            // Branch pruning: skip useless resolutions
1662
            /** @var ?string */
1663
            $storeKey = $tokenData['storeKey'] ?? null;
12,153✔
1664
            if ($this->branchPruningEnabled && isset($tokenData['onlyIf'])) {
12,153✔
1665
                /** @var string */
1666
                $onlyIfStoreKey = $tokenData['onlyIf'];
84✔
1667
                $storeValue = $branchStore[$onlyIfStoreKey] ?? null;
84✔
1668
                $storeValueAsBool = ($storeValue === null)
84✔
1669
                    ? true : (bool) Functions::flattenSingleValue($storeValue);
84✔
1670
                if (is_array($storeValue)) {
84✔
1671
                    $wrappedItem = end($storeValue);
57✔
1672
                    $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
57✔
1673
                }
1674

1675
                if (
1676
                    (isset($storeValue) || $tokenData['reference'] === 'NULL')
84✔
1677
                    && (!$storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
84✔
1678
                ) {
1679
                    // If branching value is not true, we don't need to compute
1680
                    /** @var string $onlyIfStoreKey */
1681
                    if (!isset($fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey])) {
61✔
1682
                        /** @var string $token */
1683
                        $stack->push('Value', 'Pruned branch (only if ' . $onlyIfStoreKey . ') ' . $token);
59✔
1684
                        $fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey] = true;
59✔
1685
                    }
1686

1687
                    if (isset($storeKey)) {
61✔
1688
                        // We are processing an if condition
1689
                        // We cascade the pruning to the depending branches
1690
                        $branchStore[$storeKey] = 'Pruned branch';
3✔
1691
                        $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
3✔
1692
                        $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
3✔
1693
                    }
1694

1695
                    continue;
61✔
1696
                }
1697
            }
1698

1699
            if ($this->branchPruningEnabled && isset($tokenData['onlyIfNot'])) {
12,153✔
1700
                /** @var string */
1701
                $onlyIfNotStoreKey = $tokenData['onlyIfNot'];
79✔
1702
                $storeValue = $branchStore[$onlyIfNotStoreKey] ?? null;
79✔
1703
                $storeValueAsBool = ($storeValue === null)
79✔
1704
                    ? true : (bool) Functions::flattenSingleValue($storeValue);
79✔
1705
                if (is_array($storeValue)) {
79✔
1706
                    $wrappedItem = end($storeValue);
52✔
1707
                    $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
52✔
1708
                }
1709

1710
                if (
1711
                    (isset($storeValue) || $tokenData['reference'] === 'NULL')
79✔
1712
                    && ($storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
79✔
1713
                ) {
1714
                    // If branching value is true, we don't need to compute
1715
                    if (!isset($fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey])) {
57✔
1716
                        /** @var string $token */
1717
                        $stack->push('Value', 'Pruned branch (only if not ' . $onlyIfNotStoreKey . ') ' . $token);
57✔
1718
                        $fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey] = true;
57✔
1719
                    }
1720

1721
                    if (isset($storeKey)) {
57✔
1722
                        // We are processing an if condition
1723
                        // We cascade the pruning to the depending branches
1724
                        $branchStore[$storeKey] = 'Pruned branch';
10✔
1725
                        $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
10✔
1726
                        $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
10✔
1727
                    }
1728

1729
                    continue;
57✔
1730
                }
1731
            }
1732

1733
            if ($token instanceof Operands\StructuredReference) {
12,153✔
1734
                if ($cell === null) {
17✔
UNCOV
1735
                    return $this->raiseFormulaError('Structured References must exist in a Cell context');
×
1736
                }
1737

1738
                try {
1739
                    $cellRange = $token->parse($cell);
17✔
1740
                    if (str_contains($cellRange, ':')) {
17✔
1741
                        $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell Range %s', $token->value(), $cellRange);
8✔
1742
                        $rangeValue = self::getInstance($cell->getWorksheet()->getParent())->_calculateFormulaValue("={$cellRange}", $cellRange, $cell);
8✔
1743
                        $stack->push('Value', $rangeValue);
8✔
1744
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($rangeValue));
8✔
1745
                    } else {
1746
                        $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell %s', $token->value(), $cellRange);
10✔
1747
                        $cellValue = $cell->getWorksheet()->getCell($cellRange)->getCalculatedValue(false);
10✔
1748
                        $stack->push('Cell Reference', $cellValue, $cellRange);
10✔
1749
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($cellValue));
17✔
1750
                    }
1751
                } catch (Exception $e) {
2✔
1752
                    if ($e->getCode() === Exception::CALCULATION_ENGINE_PUSH_TO_STACK) {
2✔
1753
                        $stack->push('Error', ExcelError::REF(), null);
2✔
1754
                        $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as error value %s', $token->value(), ExcelError::REF());
2✔
1755
                    } else {
UNCOV
1756
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1757
                    }
1758
                }
1759
            } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line
12,152✔
1760
                // 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
1761
                //    We must have two operands, error if we don't
1762
                $operand2Data = $stack->pop();
1,792✔
1763
                if ($operand2Data === null) {
1,792✔
1764
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
1765
                }
1766
                $operand1Data = $stack->pop();
1,792✔
1767
                if ($operand1Data === null) {
1,792✔
UNCOV
1768
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
1769
                }
1770

1771
                $operand1 = self::dataTestReference($operand1Data);
1,792✔
1772
                $operand2 = self::dataTestReference($operand2Data);
1,792✔
1773

1774
                //    Log what we're doing
1775
                if ($token == ':') {
1,792✔
1776
                    $this->debugLog->writeDebugLog('Evaluating Range %s %s %s', $this->showValue($operand1Data['reference']), $token, $this->showValue($operand2Data['reference']));
1,298✔
1777
                } else {
1778
                    $this->debugLog->writeDebugLog('Evaluating %s %s %s', $this->showValue($operand1), $token, $this->showValue($operand2));
776✔
1779
                }
1780

1781
                //    Process the operation in the appropriate manner
1782
                switch ($token) {
1783
                    // Comparison (Boolean) Operators
1784
                    case '>': // Greater than
1,792✔
1785
                    case '<': // Less than
1,777✔
1786
                    case '>=': // Greater than or Equal to
1,760✔
1787
                    case '<=': // Less than or Equal to
1,752✔
1788
                    case '=': // Equality
1,734✔
1789
                    case '<>': // Inequality
1,581✔
1790
                        $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack);
414✔
1791
                        if (isset($storeKey)) {
414✔
1792
                            $branchStore[$storeKey] = $result;
71✔
1793
                        }
1794

1795
                        break;
414✔
1796
                    // Binary Operators
1797
                    case ':': // Range
1,571✔
1798
                        if ($operand1Data['type'] === 'Error') {
1,298✔
1799
                            $stack->push($operand1Data['type'], $operand1Data['value'], null);
6✔
1800

1801
                            break;
6✔
1802
                        }
1803
                        if ($operand2Data['type'] === 'Error') {
1,293✔
1804
                            $stack->push($operand2Data['type'], $operand2Data['value'], null);
4✔
1805

1806
                            break;
4✔
1807
                        }
1808
                        if ($operand1Data['type'] === 'Defined Name') {
1,289✔
1809
                            /** @var array{reference: string} $operand1Data */
1810
                            if (preg_match('/$' . self::CALCULATION_REGEXP_DEFINEDNAME . '^/mui', $operand1Data['reference']) !== false && $this->spreadsheet !== null) {
3✔
1811
                                /** @var string[] $operand1Data */
1812
                                $definedName = $this->spreadsheet->getNamedRange($operand1Data['reference']);
3✔
1813
                                if ($definedName !== null) {
3✔
1814
                                    $operand1Data['reference'] = $operand1Data['value'] = str_replace('$', '', $definedName->getValue());
3✔
1815
                                }
1816
                            }
1817
                        }
1818
                        /** @var array{reference?: ?string} $operand1Data */
1819
                        if (str_contains($operand1Data['reference'] ?? '', '!')) {
1,289✔
1820
                            [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true, true);
1,281✔
1821
                        } else {
1822
                            $sheet1 = ($pCellWorksheet !== null) ? $pCellWorksheet->getTitle() : '';
12✔
1823
                        }
1824
                        //$sheet1 ??= ''; // phpstan level 10 says this is unneeded
1825

1826
                        /** @var string */
1827
                        $op2ref = $operand2Data['reference'];
1,289✔
1828
                        [$sheet2, $operand2Data['reference']] = Worksheet::extractSheetTitle($op2ref, true, true);
1,289✔
1829
                        if (empty($sheet2)) {
1,289✔
1830
                            $sheet2 = $sheet1;
7✔
1831
                        }
1832

1833
                        if ($sheet1 === $sheet2) {
1,289✔
1834
                            /** @var array{reference: ?string, value: string|string[]} $operand1Data */
1835
                            if ($operand1Data['reference'] === null && $cell !== null) {
1,286✔
UNCOV
1836
                                if (is_array($operand1Data['value'])) {
×
UNCOV
1837
                                    $operand1Data['reference'] = $cell->getCoordinate();
×
UNCOV
1838
                                } elseif ((trim($operand1Data['value']) != '') && (is_numeric($operand1Data['value']))) {
×
UNCOV
1839
                                    $operand1Data['reference'] = $cell->getColumn() . $operand1Data['value'];
×
UNCOV
1840
                                } elseif (trim($operand1Data['value']) == '') {
×
UNCOV
1841
                                    $operand1Data['reference'] = $cell->getCoordinate();
×
1842
                                } else {
UNCOV
1843
                                    $operand1Data['reference'] = $operand1Data['value'] . $cell->getRow();
×
1844
                                }
1845
                            }
1846
                            /** @var array{reference: ?string, value: string|string[]} $operand2Data */
1847
                            if ($operand2Data['reference'] === null && $cell !== null) {
1,286✔
1848
                                if (is_array($operand2Data['value'])) {
3✔
1849
                                    $operand2Data['reference'] = $cell->getCoordinate();
2✔
1850
                                } elseif ((trim($operand2Data['value']) != '') && (is_numeric($operand2Data['value']))) {
1✔
1851
                                    $operand2Data['reference'] = $cell->getColumn() . $operand2Data['value'];
×
1852
                                } elseif (trim($operand2Data['value']) == '') {
1✔
UNCOV
1853
                                    $operand2Data['reference'] = $cell->getCoordinate();
×
1854
                                } else {
1855
                                    $operand2Data['reference'] = $operand2Data['value'] . $cell->getRow();
1✔
1856
                                }
1857
                            }
1858

1859
                            $oData = array_merge(explode(':', $operand1Data['reference'] ?? ''), explode(':', $operand2Data['reference'] ?? ''));
1,286✔
1860
                            $oCol = $oRow = [];
1,286✔
1861
                            $breakNeeded = false;
1,286✔
1862
                            foreach ($oData as $oDatum) {
1,286✔
1863
                                try {
1864
                                    $oCR = Coordinate::coordinateFromString($oDatum);
1,286✔
1865
                                    $oCol[] = Coordinate::columnIndexFromString($oCR[0]) - 1;
1,286✔
1866
                                    $oRow[] = $oCR[1];
1,286✔
1867
                                } catch (\Exception) {
1✔
1868
                                    $stack->push('Error', ExcelError::REF(), null);
1✔
1869
                                    $breakNeeded = true;
1✔
1870

1871
                                    break;
1✔
1872
                                }
1873
                            }
1874
                            if ($breakNeeded) {
1,286✔
1875
                                break;
1✔
1876
                            }
1877
                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
1,285✔
1878
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
1,285✔
1879
                                $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
1,285✔
1880
                            } else {
UNCOV
1881
                                return $this->raiseFormulaError('Unable to access Cell Reference');
×
1882
                            }
1883

1884
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellValue));
1,285✔
1885
                            $stack->push('Cell Reference', $cellValue, $cellRef);
1,285✔
1886
                        } else {
1887
                            $this->debugLog->writeDebugLog('Evaluation Result is a #REF! Error');
4✔
1888
                            $stack->push('Error', ExcelError::REF(), null);
4✔
1889
                        }
1890

1891
                        break;
1,288✔
1892
                    case '+':            //    Addition
431✔
1893
                    case '-':            //    Subtraction
343✔
1894
                    case '*':            //    Multiplication
302✔
1895
                    case '/':            //    Division
172✔
1896
                    case '^':            //    Exponential
57✔
1897
                        $result = $this->executeNumericBinaryOperation($operand1, $operand2, $token, $stack);
392✔
1898
                        if (isset($storeKey)) {
392✔
1899
                            $branchStore[$storeKey] = $result;
6✔
1900
                        }
1901

1902
                        break;
392✔
1903
                    case '&':            //    Concatenation
53✔
1904
                        //    If either of the operands is a matrix, we need to treat them both as matrices
1905
                        //        (converting the other operand to a matrix if need be); then perform the required
1906
                        //        matrix operation
1907
                        $operand1 = self::boolToString($operand1);
29✔
1908
                        $operand2 = self::boolToString($operand2);
29✔
1909
                        if (is_array($operand1) || is_array($operand2)) {
29✔
1910
                            if (is_string($operand1)) {
17✔
1911
                                $operand1 = self::unwrapResult($operand1);
8✔
1912
                            }
1913
                            if (is_string($operand2)) {
17✔
1914
                                $operand2 = self::unwrapResult($operand2);
6✔
1915
                            }
1916
                            //    Ensure that both operands are arrays/matrices
1917
                            [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 2);
17✔
1918

1919
                            for ($row = 0; $row < $rows; ++$row) {
17✔
1920
                                for ($column = 0; $column < $columns; ++$column) {
17✔
1921
                                    /** @var mixed[][] $operand1 */
1922
                                    $op1x = self::boolToString($operand1[$row][$column]);
17✔
1923
                                    /** @var mixed[][] $operand2 */
1924
                                    $op2x = self::boolToString($operand2[$row][$column]);
17✔
1925
                                    if (Information\ErrorValue::isError($op1x)) {
17✔
1926
                                        // no need to do anything
1927
                                    } elseif (Information\ErrorValue::isError($op2x)) {
17✔
1928
                                        $operand1[$row][$column] = $op2x;
1✔
1929
                                    } else {
1930
                                        /** @var string $op1x */
1931
                                        /** @var string $op2x */
1932
                                        $operand1[$row][$column]
16✔
1933
                                            = StringHelper::substring(
16✔
1934
                                                $op1x . $op2x,
16✔
1935
                                                0,
16✔
1936
                                                DataType::MAX_STRING_LENGTH
16✔
1937
                                            );
16✔
1938
                                    }
1939
                                }
1940
                            }
1941
                            $result = $operand1;
17✔
1942
                        } else {
1943
                            if (Information\ErrorValue::isError($operand1)) {
14✔
UNCOV
1944
                                $result = $operand1;
×
1945
                            } elseif (Information\ErrorValue::isError($operand2)) {
14✔
UNCOV
1946
                                $result = $operand2;
×
1947
                            } else {
1948
                                $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line
14✔
1949
                                $result = StringHelper::substring(
14✔
1950
                                    $result,
14✔
1951
                                    0,
14✔
1952
                                    DataType::MAX_STRING_LENGTH
14✔
1953
                                );
14✔
1954
                                $result = self::FORMULA_STRING_QUOTE . $result . self::FORMULA_STRING_QUOTE;
14✔
1955
                            }
1956
                        }
1957
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
29✔
1958
                        $stack->push('Value', $result);
29✔
1959

1960
                        if (isset($storeKey)) {
29✔
UNCOV
1961
                            $branchStore[$storeKey] = $result;
×
1962
                        }
1963

1964
                        break;
29✔
1965
                    case '∩':            //    Intersect
24✔
1966
                        /** @var mixed[][] $operand1 */
1967
                        /** @var mixed[][] $operand2 */
1968
                        $rowIntersect = array_intersect_key($operand1, $operand2);
18✔
1969
                        $cellIntersect = $oCol = $oRow = [];
18✔
1970
                        foreach (array_keys($rowIntersect) as $row) {
18✔
1971
                            $oRow[] = $row;
18✔
1972
                            foreach ($rowIntersect[$row] as $col => $data) {
18✔
1973
                                $oCol[] = Coordinate::columnIndexFromString($col) - 1;
18✔
1974
                                $cellIntersect[$row] = array_intersect_key($operand1[$row], $operand2[$row]);
18✔
1975
                            }
1976
                        }
1977
                        if (count(Functions::flattenArray($cellIntersect)) === 0) {
18✔
1978
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
2✔
1979
                            $stack->push('Error', ExcelError::null(), null);
2✔
1980
                        } else {
1981
                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
16✔
1982
                                . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
16✔
1983
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
16✔
1984
                            $stack->push('Value', $cellIntersect, $cellRef);
16✔
1985
                        }
1986

1987
                        break;
18✔
1988
                    case '∪':            //    union
9✔
1989
                        /** @var mixed[][] $operand1 */
1990
                        /** @var mixed[][] $operand2 */
1991
                        $cellUnion = array_merge($operand1, $operand2);
9✔
1992
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellUnion));
9✔
1993
                        $stack->push('Value', $cellUnion, 'A1');
9✔
1994

1995
                        break;
9✔
1996
                }
1997
            } elseif (($token === '~') || ($token === '%')) {
12,145✔
1998
                // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
1999
                if (($arg = $stack->pop()) === null) {
1,169✔
UNCOV
2000
                    return $this->raiseFormulaError('Internal error - Operand value missing from stack');
×
2001
                }
2002
                $arg = $arg['value'];
1,169✔
2003
                if ($token === '~') {
1,169✔
2004
                    $this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
1,165✔
2005
                    $multiplier = -1;
1,165✔
2006
                } else {
2007
                    $this->debugLog->writeDebugLog('Evaluating Percentile of %s', $this->showValue($arg));
6✔
2008
                    $multiplier = 0.01;
6✔
2009
                }
2010
                if (is_array($arg)) {
1,169✔
2011
                    $operand2 = $multiplier;
4✔
2012
                    $result = $arg;
4✔
2013
                    [$rows, $columns] = self::checkMatrixOperands($result, $operand2, 0);
4✔
2014
                    for ($row = 0; $row < $rows; ++$row) {
4✔
2015
                        for ($column = 0; $column < $columns; ++$column) {
4✔
2016
                            /** @var mixed[][] $result */
2017
                            if (self::isNumericOrBool($result[$row][$column])) {
4✔
2018
                                /** @var float|int|numeric-string */
2019
                                $temp = $result[$row][$column];
4✔
2020
                                $result[$row][$column] = $temp * $multiplier;
4✔
2021
                            } else {
2022
                                $result[$row][$column] = self::makeError($result[$row][$column]);
2✔
2023
                            }
2024
                        }
2025
                    }
2026

2027
                    $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
4✔
2028
                    $stack->push('Value', $result);
4✔
2029
                    if (isset($storeKey)) {
4✔
UNCOV
2030
                        $branchStore[$storeKey] = $result;
×
2031
                    }
2032
                } else {
2033
                    $this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
1,168✔
2034
                }
2035
            } elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
12,145✔
2036
                $cellRef = null;
7,234✔
2037

2038
                /* Phpstan says matches[8/9/10] is never set,
2039
                   and code coverage report seems to confirm.
2040
                   regex101.com confirms - only 7 capturing groups.
2041
                   My theory is that this code expected regexp to
2042
                   match cell *or* cellRange, but it does not
2043
                   match the latter. Retain the code for now in case
2044
                   we do want to add the range match later.
2045
                   Probably delete this block later.
2046
                   Until delete happens, turn code coverage off.
2047
                */
2048
                if (isset($matches[self::$matchIndex8])) {
7,234✔
2049
                    // @codeCoverageIgnoreStart
2050
                    if ($cell === null) {
2051
                        // We can't access the range, so return a REF error
2052
                        $cellValue = ExcelError::REF();
2053
                    } else {
2054
                        $cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
2055
                        $matches[2] = (string) $matches[2];
2056
                        if ($matches[2] > '') {
2057
                            $matches[2] = trim($matches[2], "\"'");
2058
                            if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
2059
                                //    It's a Reference to an external spreadsheet (not currently supported)
2060
                                return $this->raiseFormulaError('Unable to access External Workbook');
2061
                            }
2062
                            $matches[2] = trim($matches[2], "\"'");
2063
                            $this->debugLog->writeDebugLog('Evaluating Cell Range %s in worksheet %s', $cellRef, $matches[2]);
2064
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
2065
                                $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
2066
                            } else {
2067
                                return $this->raiseFormulaError('Unable to access Cell Reference');
2068
                            }
2069
                            $this->debugLog->writeDebugLog('Evaluation Result for cells %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
2070
                        } else {
2071
                            $this->debugLog->writeDebugLog('Evaluating Cell Range %s in current worksheet', $cellRef);
2072
                            if ($pCellParent !== null) {
2073
                                $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
2074
                            } else {
2075
                                return $this->raiseFormulaError('Unable to access Cell Reference');
2076
                            }
2077
                            $this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
2078
                        }
2079
                    }
2080
                    // @codeCoverageIgnoreEnd
2081
                } else {
2082
                    if ($cell === null) {
7,234✔
2083
                        // We can't access the cell, so return a REF error
UNCOV
2084
                        $cellValue = ExcelError::REF();
×
2085
                    } else {
2086
                        $cellRef = $matches[6] . $matches[7];
7,234✔
2087
                        $matches[2] = (string) $matches[2];
7,234✔
2088
                        if ($matches[2] > '') {
7,234✔
2089
                            $matches[2] = trim($matches[2], "\"'");
7,227✔
2090
                            if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
7,227✔
2091
                                //    It's a Reference to an external spreadsheet (not currently supported)
2092
                                return $this->raiseFormulaError('Unable to access External Workbook');
1✔
2093
                            }
2094
                            $this->debugLog->writeDebugLog('Evaluating Cell %s in worksheet %s', $cellRef, $matches[2]);
7,227✔
2095
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
7,227✔
2096
                                $cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
7,227✔
2097
                                if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
7,227✔
2098
                                    try {
2099
                                        $cellSheet->setCellValue($cellRef, null);
348✔
2100
                                    } catch (SpreadsheetException) {
6✔
2101
                                        // do nothing
2102
                                    }
2103
                                }
2104
                                if ($cellSheet && $cellSheet->cellExists($cellRef)) {
7,227✔
2105
                                    $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
7,213✔
2106
                                    $cell->attach($pCellParent);
7,213✔
2107
                                } else {
2108
                                    $cellRef = ($cellSheet !== null) ? "'{$matches[2]}'!{$cellRef}" : $cellRef;
27✔
2109
                                    $cellValue = ($cellSheet !== null) ? null : ExcelError::REF();
27✔
2110
                                }
2111
                            } else {
UNCOV
2112
                                return $this->raiseFormulaError('Unable to access Cell Reference');
×
2113
                            }
2114
                            $this->debugLog->writeDebugLog('Evaluation Result for cell %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
7,227✔
2115
                        } else {
2116
                            $this->debugLog->writeDebugLog('Evaluating Cell %s in current worksheet', $cellRef);
12✔
2117
                            if ($pCellParent !== null && $pCellParent->has($cellRef)) {
12✔
2118
                                $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
12✔
2119
                                $cell->attach($pCellParent);
12✔
2120
                            } else {
2121
                                $cellValue = null;
2✔
2122
                            }
2123
                            $this->debugLog->writeDebugLog('Evaluation Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
12✔
2124
                        }
2125
                    }
2126
                }
2127

2128
                if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY && !$this->processingAnchorArray && is_array($cellValue)) {
7,234✔
2129
                    while (is_array($cellValue)) {
213✔
2130
                        $cellValue = array_shift($cellValue);
213✔
2131
                    }
2132
                    if (is_string($cellValue)) {
213✔
2133
                        $cellValue = Preg::replace('/"/', '""', $cellValue);
139✔
2134
                    }
2135
                    $this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
213✔
2136
                }
2137
                $this->processingAnchorArray = false;
7,234✔
2138
                $stack->push('Cell Value', $cellValue, $cellRef);
7,234✔
2139
                if (isset($storeKey)) {
7,234✔
2140
                    $branchStore[$storeKey] = $cellValue;
59✔
2141
                }
2142
            } elseif (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($token ?? ''), $matches)) {
12,061✔
2143
                // if the token is a function, pop arguments off the stack, hand them to the function, and push the result back on
2144
                if ($cell !== null && $pCellParent !== null) {
11,850✔
2145
                    $cell->attach($pCellParent);
8,259✔
2146
                }
2147

2148
                $functionName = $matches[1];
11,850✔
2149
                /** @var array<string, int> $argCount */
2150
                $argCount = $stack->pop();
11,850✔
2151
                $argCount = $argCount['value'];
11,850✔
2152
                if ($functionName !== 'MKMATRIX') {
11,850✔
2153
                    $this->debugLog->writeDebugLog('Evaluating Function %s() with %s argument%s', self::localeFunc($functionName), (($argCount == 0) ? 'no' : $argCount), (($argCount == 1) ? '' : 's'));
11,849✔
2154
                }
2155
                if ((isset($phpSpreadsheetFunctions[$functionName])) || (isset(self::$controlFunctions[$functionName]))) {    // function
11,850✔
2156
                    $passByReference = false;
11,850✔
2157
                    $passCellReference = false;
11,850✔
2158
                    $functionCall = null;
11,850✔
2159
                    if (isset($phpSpreadsheetFunctions[$functionName])) {
11,850✔
2160
                        $functionCall = $phpSpreadsheetFunctions[$functionName]['functionCall'];
11,847✔
2161
                        $passByReference = isset($phpSpreadsheetFunctions[$functionName]['passByReference']);
11,847✔
2162
                        $passCellReference = isset($phpSpreadsheetFunctions[$functionName]['passCellReference']);
11,847✔
2163
                    } elseif (isset(self::$controlFunctions[$functionName])) {
844✔
2164
                        $functionCall = self::$controlFunctions[$functionName]['functionCall'];
844✔
2165
                        $passByReference = isset(self::$controlFunctions[$functionName]['passByReference']);
844✔
2166
                        $passCellReference = isset(self::$controlFunctions[$functionName]['passCellReference']);
844✔
2167
                    }
2168

2169
                    // get the arguments for this function
2170
                    $args = $argArrayVals = [];
11,850✔
2171
                    $emptyArguments = [];
11,850✔
2172
                    for ($i = 0; $i < $argCount; ++$i) {
11,850✔
2173
                        $arg = $stack->pop();
11,831✔
2174
                        $a = $argCount - $i - 1;
11,831✔
2175
                        if (
2176
                            ($passByReference)
11,831✔
2177
                            && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line
11,831✔
2178
                            && ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
11,831✔
2179
                        ) {
2180
                            /** @var mixed[] $arg */
2181
                            if ($arg['reference'] === null) {
92✔
2182
                                $nextArg = $cellID;
19✔
2183
                                if ($functionName === 'ISREF' && ($arg['type'] ?? '') === 'Value') {
19✔
2184
                                    if (array_key_exists('value', $arg)) {
5✔
2185
                                        $argValue = $arg['value'];
5✔
2186
                                        if (is_scalar($argValue)) {
5✔
2187
                                            $nextArg = $argValue;
2✔
2188
                                        } elseif (empty($argValue)) {
3✔
2189
                                            $nextArg = '';
1✔
2190
                                        }
2191
                                    }
2192
                                } elseif (($arg['type'] ?? '') === 'Error') {
14✔
2193
                                    $argValue = $arg['value'];
13✔
2194
                                    if (is_scalar($argValue)) {
13✔
2195
                                        $nextArg = $argValue;
13✔
UNCOV
2196
                                    } elseif (empty($argValue)) {
×
UNCOV
2197
                                        $nextArg = '';
×
2198
                                    }
2199
                                }
2200
                                $args[] = $nextArg;
19✔
2201
                                if ($functionName !== 'MKMATRIX') {
19✔
2202
                                    $argArrayVals[] = $this->showValue($cellID);
19✔
2203
                                }
2204
                            } else {
2205
                                $args[] = $arg['reference'];
77✔
2206
                                if ($functionName !== 'MKMATRIX') {
77✔
2207
                                    $argArrayVals[] = $this->showValue($arg['reference']);
77✔
2208
                                }
2209
                            }
2210
                        } else {
2211
                            /** @var mixed[] $arg */
2212
                            if ($arg['type'] === 'Empty Argument' && in_array($functionName, ['MIN', 'MINA', 'MAX', 'MAXA', 'IF'], true)) {
11,765✔
2213
                                $emptyArguments[] = false;
15✔
2214
                                $args[] = $arg['value'] = 0;
15✔
2215
                                $this->debugLog->writeDebugLog('Empty Argument reevaluated as 0');
15✔
2216
                            } else {
2217
                                $emptyArguments[] = $arg['type'] === 'Empty Argument';
11,765✔
2218
                                $args[] = self::unwrapResult($arg['value']);
11,765✔
2219
                            }
2220
                            if ($functionName !== 'MKMATRIX') {
11,765✔
2221
                                $argArrayVals[] = $this->showValue($arg['value']);
11,764✔
2222
                            }
2223
                        }
2224
                    }
2225

2226
                    //    Reverse the order of the arguments
2227
                    krsort($args);
11,850✔
2228
                    krsort($emptyArguments);
11,850✔
2229

2230
                    if ($argCount > 0 && is_array($functionCall)) {
11,850✔
2231
                        /** @var string[] */
2232
                        $functionCallCopy = $functionCall;
11,831✔
2233
                        $args = $this->addDefaultArgumentValues($functionCallCopy, $args, $emptyArguments);
11,831✔
2234
                    }
2235

2236
                    if (($passByReference) && ($argCount == 0)) {
11,850✔
2237
                        $args[] = $cellID;
9✔
2238
                        $argArrayVals[] = $this->showValue($cellID);
9✔
2239
                    }
2240

2241
                    if ($functionName !== 'MKMATRIX') {
11,850✔
2242
                        if ($this->debugLog->getWriteDebugLog()) {
11,849✔
2243
                            krsort($argArrayVals);
2✔
2244
                            $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
2✔
2245
                        }
2246
                    }
2247

2248
                    //    Process the argument with the appropriate function call
2249
                    if ($pCellWorksheet !== null && $originalCoordinate !== null) {
11,850✔
2250
                        $pCellWorksheet->getCell($originalCoordinate);
8,259✔
2251
                    }
2252
                    /** @var array<string>|string $functionCall */
2253
                    $args = $this->addCellReference($args, $passCellReference, $functionCall, $cell);
11,850✔
2254

2255
                    if (!is_array($functionCall)) {
11,850✔
2256
                        foreach ($args as &$arg) {
54✔
2257
                            $arg = Functions::flattenSingleValue($arg);
1✔
2258
                        }
2259
                        unset($arg);
54✔
2260
                    }
2261

2262
                    /** @var callable $functionCall */
2263
                    try {
2264
                        $result = call_user_func_array($functionCall, $args);
11,850✔
2265
                    } catch (TypeError $e) {
12✔
UNCOV
2266
                        if (!$this->suppressFormulaErrors) {
×
UNCOV
2267
                            throw $e;
×
2268
                        }
UNCOV
2269
                        $result = false;
×
2270
                    }
2271
                    if ($functionName !== 'MKMATRIX') {
11,845✔
2272
                        $this->debugLog->writeDebugLog('Evaluation Result for %s() function call is %s', self::localeFunc($functionName), $this->showTypeDetails($result));
11,842✔
2273
                    }
2274
                    $stack->push('Value', self::wrapResult($result));
11,845✔
2275
                    if (isset($storeKey)) {
11,845✔
2276
                        $branchStore[$storeKey] = $result;
23✔
2277
                    }
2278
                }
2279
            } else {
2280
                // if the token is a number, boolean, string or an Excel error, push it onto the stack
2281
                /** @var ?string $token */
2282
                if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
12,061✔
UNCOV
2283
                    $excelConstant = strtoupper("$token");
×
UNCOV
2284
                    $stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
×
UNCOV
2285
                    if (isset($storeKey)) {
×
UNCOV
2286
                        $branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
×
2287
                    }
UNCOV
2288
                    $this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
×
2289
                } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line
12,061✔
2290
                    /** @var array{type: string, reference: ?string} $tokenData */
2291
                    $stack->push($tokenData['type'], $token, $tokenData['reference']);
12,022✔
2292
                    if (isset($storeKey)) {
12,022✔
2293
                        $branchStore[$storeKey] = $token;
74✔
2294
                    }
2295
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/miu', $token, $matches)) {
162✔
2296
                    // if the token is a named range or formula, evaluate it and push the result onto the stack
2297
                    $definedName = $matches[6];
162✔
2298
                    if (str_starts_with($definedName, '_xleta')) {
162✔
2299
                        return Functions::NOT_YET_IMPLEMENTED;
1✔
2300
                    }
2301
                    if ($cell === null || $pCellWorksheet === null) {
161✔
UNCOV
2302
                        return $this->raiseFormulaError("undefined name '$token'");
×
2303
                    }
2304
                    $specifiedWorksheet = trim($matches[2], "'");
161✔
2305

2306
                    $this->debugLog->writeDebugLog('Evaluating Defined Name %s', $definedName);
161✔
2307
                    $namedRange = DefinedName::resolveName($definedName, $pCellWorksheet, $specifiedWorksheet);
161✔
2308
                    // If not Defined Name, try as Table.
2309
                    if ($namedRange === null && $this->spreadsheet !== null) {
161✔
2310
                        $table = $this->spreadsheet->getTableByName($definedName);
38✔
2311
                        if ($table !== null) {
38✔
2312
                            $tableRange = Coordinate::getRangeBoundaries($table->getRange());
3✔
2313
                            if ($table->getShowHeaderRow()) {
3✔
2314
                                ++$tableRange[0][1];
3✔
2315
                            }
2316
                            if ($table->getShowTotalsRow()) {
3✔
UNCOV
2317
                                --$tableRange[1][1];
×
2318
                            }
2319
                            $tableRangeString
3✔
2320
                                = '$' . $tableRange[0][0]
3✔
2321
                                . '$' . $tableRange[0][1]
3✔
2322
                                . ':'
3✔
2323
                                . '$' . $tableRange[1][0]
3✔
2324
                                . '$' . $tableRange[1][1];
3✔
2325
                            $namedRange = new NamedRange($definedName, $table->getWorksheet(), $tableRangeString);
3✔
2326
                        }
2327
                    }
2328
                    if ($namedRange === null) {
161✔
2329
                        $result = ExcelError::NAME();
35✔
2330
                        $stack->push('Error', $result, null);
35✔
2331
                        $this->debugLog->writeDebugLog("Error $result");
35✔
2332
                    } else {
2333
                        $result = $this->evaluateDefinedName($cell, $namedRange, $pCellWorksheet, $stack, $specifiedWorksheet !== '');
138✔
2334
                    }
2335

2336
                    if (isset($storeKey)) {
161✔
2337
                        $branchStore[$storeKey] = $result;
1✔
2338
                    }
2339
                } else {
UNCOV
2340
                    return $this->raiseFormulaError("undefined name '$token'");
×
2341
                }
2342
            }
2343
        }
2344
        // when we're out of tokens, the stack should have a single element, the final result
2345
        if ($stack->count() != 1) {
12,144✔
2346
            return $this->raiseFormulaError('internal error');
1✔
2347
        }
2348
        /** @var array<string, array<int, mixed>|false|string> */
2349
        $output = $stack->pop();
12,144✔
2350
        $output = $output['value'];
12,144✔
2351

2352
        return $output;
12,144✔
2353
    }
2354

2355
    private function validateBinaryOperand(mixed &$operand, Stack &$stack): bool
1,493✔
2356
    {
2357
        if (is_array($operand)) {
1,493✔
2358
            if ((count($operand, COUNT_RECURSIVE) - count($operand)) == 1) {
229✔
2359
                do {
2360
                    $operand = array_pop($operand);
194✔
2361
                } while (is_array($operand));
194✔
2362
            }
2363
        }
2364
        //    Numbers, matrices and booleans can pass straight through, as they're already valid
2365
        if (is_string($operand)) {
1,493✔
2366
            //    We only need special validations for the operand if it is a string
2367
            //    Start by stripping off the quotation marks we use to identify true excel string values internally
2368
            if ($operand > '' && $operand[0] == self::FORMULA_STRING_QUOTE) {
17✔
2369
                $operand = StringHelper::convertToString(self::unwrapResult($operand));
5✔
2370
            }
2371
            //    If the string is a numeric value, we treat it as a numeric, so no further testing
2372
            if (!is_numeric($operand)) {
17✔
2373
                //    If not a numeric, test to see if the value is an Excel error, and so can't be used in normal binary operations
2374
                if ($operand > '' && $operand[0] == '#') {
15✔
2375
                    $stack->push('Value', $operand);
6✔
2376
                    $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($operand));
6✔
2377

2378
                    return false;
6✔
2379
                } elseif (Engine\FormattedNumber::convertToNumberIfFormatted($operand) === false) {
11✔
2380
                    //    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
2381
                    $stack->push('Error', '#VALUE!');
6✔
2382
                    $this->debugLog->writeDebugLog('Evaluation Result is a %s', $this->showTypeDetails('#VALUE!'));
6✔
2383

2384
                    return false;
6✔
2385
                }
2386
            }
2387
        }
2388

2389
        //    return a true if the value of the operand is one that we can use in normal binary mathematical operations
2390
        return true;
1,491✔
2391
    }
2392

2393
    /** @return mixed[] */
2394
    private function executeArrayComparison(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays): array
56✔
2395
    {
2396
        $result = [];
56✔
2397
        if (!is_array($operand2) && is_array($operand1)) {
56✔
2398
            // Operand 1 is an array, Operand 2 is a scalar
2399
            foreach ($operand1 as $x => $operandData) {
53✔
2400
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2));
53✔
2401
                $this->executeBinaryComparisonOperation($operandData, $operand2, $operation, $stack);
53✔
2402
                /** @var array<string, mixed> $r */
2403
                $r = $stack->pop();
53✔
2404
                $result[$x] = $r['value'];
53✔
2405
            }
2406
        } elseif (is_array($operand2) && !is_array($operand1)) {
10✔
2407
            // Operand 1 is a scalar, Operand 2 is an array
2408
            foreach ($operand2 as $x => $operandData) {
1✔
2409
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operand1), $operation, $this->showValue($operandData));
1✔
2410
                $this->executeBinaryComparisonOperation($operand1, $operandData, $operation, $stack);
1✔
2411
                /** @var array<string, mixed> $r */
2412
                $r = $stack->pop();
1✔
2413
                $result[$x] = $r['value'];
1✔
2414
            }
2415
        } elseif (is_array($operand2) && is_array($operand1)) {
9✔
2416
            // Operand 1 and Operand 2 are both arrays
2417
            if (!$recursingArrays) {
9✔
2418
                self::checkMatrixOperands($operand1, $operand2, 2);
9✔
2419
            }
2420
            foreach ($operand1 as $x => $operandData) {
9✔
2421
                $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2[$x]));
9✔
2422
                $this->executeBinaryComparisonOperation($operandData, $operand2[$x], $operation, $stack, true);
9✔
2423
                /** @var array<string, mixed> $r */
2424
                $r = $stack->pop();
9✔
2425
                $result[$x] = $r['value'];
9✔
2426
            }
2427
        } else {
UNCOV
2428
            throw new Exception('Neither operand is an arra');
×
2429
        }
2430
        //    Log the result details
2431
        $this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
56✔
2432
        //    And push the result onto the stack
2433
        $stack->push('Array', $result);
56✔
2434

2435
        return $result;
56✔
2436
    }
2437

2438
    /** @return array<mixed>|bool|string */
2439
    private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
414✔
2440
    {
2441
        //    If we're dealing with matrix operations, we want a matrix result
2442
        if ((is_array($operand1)) || (is_array($operand2))) {
414✔
2443
            return $this->executeArrayComparison($operand1, $operand2, $operation, $stack, $recursingArrays);
56✔
2444
        }
2445

2446
        $result = BinaryComparison::compare($operand1, $operand2, $operation);
414✔
2447

2448
        //    Log the result details
2449
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
414✔
2450
        //    And push the result onto the stack
2451
        $stack->push('Value', $result);
414✔
2452

2453
        return $result;
414✔
2454
    }
2455

2456
    private function executeNumericBinaryOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack): mixed
1,493✔
2457
    {
2458
        //    Validate the two operands
2459
        if (
2460
            ($this->validateBinaryOperand($operand1, $stack) === false)
1,493✔
2461
            || ($this->validateBinaryOperand($operand2, $stack) === false)
1,493✔
2462
        ) {
2463
            return false;
10✔
2464
        }
2465

2466
        if (
2467
            (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE)
1,486✔
2468
            && ((is_string($operand1) && !is_numeric($operand1) && $operand1 !== '')
1,486✔
2469
                || (is_string($operand2) && !is_numeric($operand2) && $operand2 !== ''))
1,486✔
2470
        ) {
UNCOV
2471
            $result = ExcelError::VALUE();
×
2472
        } elseif (is_array($operand1) || is_array($operand2)) {
1,486✔
2473
            //    Ensure that both operands are arrays/matrices
2474
            if (is_array($operand1)) {
38✔
2475
                foreach ($operand1 as $key => $value) {
32✔
2476
                    $operand1[$key] = Functions::flattenArray($value);
32✔
2477
                }
2478
            }
2479
            if (is_array($operand2)) {
38✔
2480
                foreach ($operand2 as $key => $value) {
31✔
2481
                    $operand2[$key] = Functions::flattenArray($value);
31✔
2482
                }
2483
            }
2484
            [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 3);
38✔
2485

2486
            for ($row = 0; $row < $rows; ++$row) {
38✔
2487
                for ($column = 0; $column < $columns; ++$column) {
38✔
2488
                    /** @var mixed[][] $operand1 */
2489
                    if (($operand1[$row][$column] ?? null) === null) {
38✔
2490
                        $operand1[$row][$column] = 0;
2✔
2491
                    } elseif (!self::isNumericOrBool($operand1[$row][$column])) {
38✔
2492
                        $operand1[$row][$column] = self::makeError($operand1[$row][$column]);
1✔
2493

2494
                        continue;
1✔
2495
                    }
2496
                    /** @var mixed[][] $operand2 */
2497
                    if (($operand2[$row][$column] ?? null) === null) {
38✔
2498
                        $operand2[$row][$column] = 0;
2✔
2499
                    } elseif (!self::isNumericOrBool($operand2[$row][$column])) {
38✔
UNCOV
2500
                        $operand1[$row][$column] = self::makeError($operand2[$row][$column]);
×
2501

UNCOV
2502
                        continue;
×
2503
                    }
2504
                    /** @var float|int */
2505
                    $operand1Val = $operand1[$row][$column];
38✔
2506
                    /** @var float|int */
2507
                    $operand2Val = $operand2[$row][$column];
38✔
2508
                    switch ($operation) {
2509
                        case '+':
38✔
2510
                            $operand1[$row][$column] = $operand1Val + $operand2Val;
4✔
2511

2512
                            break;
4✔
2513
                        case '-':
35✔
2514
                            $operand1[$row][$column] = $operand1Val - $operand2Val;
4✔
2515

2516
                            break;
4✔
2517
                        case '*':
32✔
2518
                            $operand1[$row][$column] = $operand1Val * $operand2Val;
25✔
2519

2520
                            break;
25✔
2521
                        case '/':
7✔
2522
                            if ($operand2Val == 0) {
5✔
2523
                                $operand1[$row][$column] = ExcelError::DIV0();
3✔
2524
                            } else {
2525
                                $operand1[$row][$column] = $operand1Val / $operand2Val;
4✔
2526
                            }
2527

2528
                            break;
5✔
2529
                        case '^':
2✔
2530
                            $operand1[$row][$column] = $operand1Val ** $operand2Val;
2✔
2531

2532
                            break;
2✔
2533

2534
                        default:
UNCOV
2535
                            throw new Exception('Unsupported numeric binary operation');
×
2536
                    }
2537
                }
2538
            }
2539
            $result = $operand1;
38✔
2540
        } else {
2541
            //    If we're dealing with non-matrix operations, execute the necessary operation
2542
            /** @var float|int $operand1 */
2543
            /** @var float|int $operand2 */
2544
            switch ($operation) {
2545
                //    Addition
2546
                case '+':
1,468✔
2547
                    $result = $operand1 + $operand2;
170✔
2548

2549
                    break;
170✔
2550
                //    Subtraction
2551
                case '-':
1,380✔
2552
                    $result = $operand1 - $operand2;
52✔
2553

2554
                    break;
52✔
2555
                //    Multiplication
2556
                case '*':
1,344✔
2557
                    $result = $operand1 * $operand2;
1,266✔
2558

2559
                    break;
1,266✔
2560
                //    Division
2561
                case '/':
115✔
2562
                    if ($operand2 == 0) {
113✔
2563
                        //    Trap for Divide by Zero error
2564
                        $stack->push('Error', ExcelError::DIV0());
62✔
2565
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails(ExcelError::DIV0()));
62✔
2566

2567
                        return false;
62✔
2568
                    }
2569
                    $result = $operand1 / $operand2;
61✔
2570

2571
                    break;
61✔
2572
                //    Power
2573
                case '^':
3✔
2574
                    $result = $operand1 ** $operand2;
3✔
2575

2576
                    break;
3✔
2577

2578
                default:
UNCOV
2579
                    throw new Exception('Unsupported numeric binary operation');
×
2580
            }
2581
        }
2582

2583
        //    Log the result details
2584
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
1,442✔
2585
        //    And push the result onto the stack
2586
        $stack->push('Value', $result);
1,442✔
2587

2588
        return $result;
1,442✔
2589
    }
2590

2591
    /**
2592
     * Trigger an error, but nicely, if need be.
2593
     *
2594
     * @return false
2595
     */
2596
    protected function raiseFormulaError(string $errorMessage, int $code = 0, ?Throwable $exception = null): bool
248✔
2597
    {
2598
        $this->formulaError = $errorMessage;
248✔
2599
        $this->cyclicReferenceStack->clear();
248✔
2600
        $suppress = $this->suppressFormulaErrors;
248✔
2601
        $suppressed = $suppress ? ' $suppressed' : '';
248✔
2602
        $this->debugLog->writeDebugLog("Raise Error$suppressed $errorMessage");
248✔
2603
        if (!$suppress) {
248✔
2604
            throw new Exception($errorMessage, $code, $exception);
247✔
2605
        }
2606

2607
        return false;
2✔
2608
    }
2609

2610
    /**
2611
     * Extract range values.
2612
     *
2613
     * @param string $range String based range representation
2614
     * @param ?Worksheet $worksheet Worksheet
2615
     * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2616
     *
2617
     * @return mixed[] Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2618
     */
2619
    public function extractCellRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true, bool $createCell = false): array
7,267✔
2620
    {
2621
        // Return value
2622
        /** @var mixed[][] */
2623
        $returnValue = [];
7,267✔
2624

2625
        if ($worksheet !== null) {
7,267✔
2626
            $worksheetName = $worksheet->getTitle();
7,266✔
2627

2628
            if (str_contains($range, '!')) {
7,266✔
2629
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
10✔
2630
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
10✔
2631
            }
2632

2633
            // Extract range
2634
            $aReferences = Coordinate::extractAllCellReferencesInRange($range);
7,266✔
2635
            $range = "'" . $worksheetName . "'" . '!' . $range;
7,266✔
2636
            $currentCol = '';
7,266✔
2637
            $currentRow = 0;
7,266✔
2638
            if (!isset($aReferences[1])) {
7,266✔
2639
                //    Single cell in range
2640
                sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
7,262✔
2641
                /** @var string $currentCol */
2642
                /** @var int $currentRow */
2643
                if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
7,262✔
2644
                    $worksheet->setCellValue($aReferences[0], null);
2✔
2645
                }
2646
                if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
7,262✔
2647
                    $temp = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
7,262✔
2648
                    if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
7,262✔
2649
                        while (is_array($temp)) {
215✔
2650
                            $temp = array_shift($temp);
8✔
2651
                        }
2652
                    }
2653
                    $returnValue[$currentRow][$currentCol] = $temp;
7,262✔
2654
                } else {
UNCOV
2655
                    $returnValue[$currentRow][$currentCol] = null;
×
2656
                }
2657
            } else {
2658
                // Extract cell data for all cells in the range
2659
                foreach ($aReferences as $reference) {
1,284✔
2660
                    // Extract range
2661
                    sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
1,284✔
2662
                    /** @var string $currentCol */
2663
                    /** @var int $currentRow */
2664
                    if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
1,284✔
2665
                        $worksheet->setCellValue($reference, null);
4✔
2666
                    }
2667
                    if ($worksheet !== null && $worksheet->cellExists($reference)) {
1,284✔
2668
                        $temp = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
1,282✔
2669
                        if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
1,282✔
2670
                            while (is_array($temp)) {
190✔
2671
                                $temp = array_shift($temp);
1✔
2672
                            }
2673
                        }
2674
                        $returnValue[$currentRow][$currentCol] = $temp;
1,282✔
2675
                    } else {
2676
                        $returnValue[$currentRow][$currentCol] = null;
137✔
2677
                    }
2678
                }
2679
            }
2680
        }
2681

2682
        return $returnValue;
7,267✔
2683
    }
2684

2685
    /**
2686
     * Extract range values.
2687
     *
2688
     * @param string $range String based range representation
2689
     * @param null|Worksheet $worksheet Worksheet
2690
     * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2691
     *
2692
     * @return mixed[]|string Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2693
     */
2694
    public function extractNamedRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true): string|array
1✔
2695
    {
2696
        // Return value
2697
        $returnValue = [];
1✔
2698

2699
        if ($worksheet !== null) {
1✔
2700
            if (str_contains($range, '!')) {
1✔
2701
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
1✔
2702
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
1✔
2703
            }
2704

2705
            // Named range?
2706
            $namedRange = ($worksheet === null) ? null : DefinedName::resolveName($range, $worksheet);
1✔
2707
            if ($namedRange === null) {
1✔
2708
                return ExcelError::REF();
1✔
2709
            }
2710

2711
            $worksheet = $namedRange->getWorksheet();
1✔
2712
            $range = $namedRange->getValue();
1✔
2713
            $splitRange = Coordinate::splitRange($range);
1✔
2714
            //    Convert row and column references
2715
            if ($worksheet !== null && ctype_alpha($splitRange[0][0])) {
1✔
UNCOV
2716
                $range = $splitRange[0][0] . '1:' . $splitRange[0][1] . $worksheet->getHighestRow();
×
2717
            } elseif ($worksheet !== null && ctype_digit($splitRange[0][0])) {
1✔
UNCOV
2718
                $range = 'A' . $splitRange[0][0] . ':' . $worksheet->getHighestColumn() . $splitRange[0][1];
×
2719
            }
2720

2721
            // Extract range
2722
            $aReferences = Coordinate::extractAllCellReferencesInRange($range);
1✔
2723
            if (!isset($aReferences[1])) {
1✔
2724
                //    Single cell (or single column or row) in range
2725
                [$currentCol, $currentRow] = Coordinate::coordinateFromString($aReferences[0]);
1✔
2726
                /** @var mixed[][] $returnValue */
2727
                if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
1✔
2728
                    $returnValue[$currentRow][$currentCol] = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
1✔
2729
                } else {
2730
                    $returnValue[$currentRow][$currentCol] = null;
1✔
2731
                }
2732
            } else {
2733
                // Extract cell data for all cells in the range
2734
                foreach ($aReferences as $reference) {
1✔
2735
                    // Extract range
2736
                    [$currentCol, $currentRow] = Coordinate::coordinateFromString($reference);
1✔
2737
                    if ($worksheet !== null && $worksheet->cellExists($reference)) {
1✔
2738
                        $returnValue[$currentRow][$currentCol] = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
1✔
2739
                    } else {
2740
                        $returnValue[$currentRow][$currentCol] = null;
1✔
2741
                    }
2742
                }
2743
            }
2744
        }
2745

2746
        return $returnValue;
1✔
2747
    }
2748

2749
    /**
2750
     * Is a specific function implemented?
2751
     *
2752
     * @param string $function Function Name
2753
     */
2754
    public function isImplemented(string $function): bool
3✔
2755
    {
2756
        $function = strtoupper($function);
3✔
2757
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
3✔
2758
        $notImplemented = !isset($phpSpreadsheetFunctions[$function]) || (is_array($phpSpreadsheetFunctions[$function]['functionCall']) && $phpSpreadsheetFunctions[$function]['functionCall'][1] === 'DUMMY');
3✔
2759

2760
        return !$notImplemented;
3✔
2761
    }
2762

2763
    /**
2764
     * Get a list of implemented Excel function names.
2765
     *
2766
     * @return string[]
2767
     */
2768
    public function getImplementedFunctionNames(): array
2✔
2769
    {
2770
        $returnValue = [];
2✔
2771
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
2✔
2772
        foreach ($phpSpreadsheetFunctions as $functionName => $function) {
2✔
2773
            if ($this->isImplemented($functionName)) {
2✔
2774
                $returnValue[] = $functionName;
2✔
2775
            }
2776
        }
2777

2778
        return $returnValue;
2✔
2779
    }
2780

2781
    /**
2782
     * @param string[] $functionCall
2783
     * @param mixed[] $args
2784
     * @param mixed[] $emptyArguments
2785
     *
2786
     * @return mixed[]
2787
     */
2788
    private function addDefaultArgumentValues(array $functionCall, array $args, array $emptyArguments): array
11,831✔
2789
    {
2790
        $reflector = new ReflectionMethod($functionCall[0], $functionCall[1]);
11,831✔
2791
        $methodArguments = $reflector->getParameters();
11,831✔
2792

2793
        if (count($methodArguments) > 0) {
11,831✔
2794
            // Apply any defaults for empty argument values
2795
            foreach ($emptyArguments as $argumentId => $isArgumentEmpty) {
11,831✔
2796
                if ($isArgumentEmpty === true) {
11,765✔
2797
                    $reflectedArgumentId = count($args) - (int) $argumentId - 1;
148✔
2798
                    if (
2799
                        !array_key_exists($reflectedArgumentId, $methodArguments)
148✔
2800
                        || $methodArguments[$reflectedArgumentId]->isVariadic()
148✔
2801
                    ) {
2802
                        break;
13✔
2803
                    }
2804

2805
                    $args[$argumentId] = $this->getArgumentDefaultValue($methodArguments[$reflectedArgumentId]);
135✔
2806
                }
2807
            }
2808
        }
2809

2810
        return $args;
11,831✔
2811
    }
2812

2813
    private function getArgumentDefaultValue(ReflectionParameter $methodArgument): mixed
135✔
2814
    {
2815
        $defaultValue = null;
135✔
2816

2817
        if ($methodArgument->isDefaultValueAvailable()) {
135✔
2818
            $defaultValue = $methodArgument->getDefaultValue();
63✔
2819
            if ($methodArgument->isDefaultValueConstant()) {
63✔
2820
                $constantName = $methodArgument->getDefaultValueConstantName() ?? '';
2✔
2821
                // read constant value
2822
                if (str_contains($constantName, '::')) {
2✔
2823
                    [$className, $constantName] = explode('::', $constantName);
2✔
2824
                    /** @var class-string $className */
2825
                    $constantReflector = new ReflectionClassConstant($className, $constantName);
2✔
2826

2827
                    return $constantReflector->getValue();
2✔
2828
                }
2829

UNCOV
2830
                return constant($constantName);
×
2831
            }
2832
        }
2833

2834
        return $defaultValue;
134✔
2835
    }
2836

2837
    /**
2838
     * Add cell reference if needed while making sure that it is the last argument.
2839
     *
2840
     * @param mixed[] $args
2841
     * @param string|string[] $functionCall
2842
     *
2843
     * @return mixed[]
2844
     */
2845
    private function addCellReference(array $args, bool $passCellReference, array|string $functionCall, ?Cell $cell = null): array
11,850✔
2846
    {
2847
        if ($passCellReference) {
11,850✔
2848
            if (is_array($functionCall)) {
313✔
2849
                $className = $functionCall[0];
313✔
2850
                $methodName = $functionCall[1];
313✔
2851

2852
                $reflectionMethod = new ReflectionMethod($className, $methodName);
313✔
2853
                $argumentCount = count($reflectionMethod->getParameters());
313✔
2854
                while (count($args) < $argumentCount - 1) {
313✔
2855
                    $args[] = null;
57✔
2856
                }
2857
            }
2858

2859
            $args[] = $cell;
313✔
2860
        }
2861

2862
        return $args;
11,850✔
2863
    }
2864

2865
    private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksheet $cellWorksheet, Stack $stack, bool $ignoreScope = false): mixed
138✔
2866
    {
2867
        $definedNameScope = $namedRange->getScope();
138✔
2868
        if ($definedNameScope !== null && $definedNameScope !== $cellWorksheet && !$ignoreScope) {
138✔
2869
            // The defined name isn't in our current scope, so #REF
UNCOV
2870
            $result = ExcelError::REF();
×
UNCOV
2871
            $stack->push('Error', $result, $namedRange->getName());
×
2872

UNCOV
2873
            return $result;
×
2874
        }
2875

2876
        $definedNameValue = $namedRange->getValue();
138✔
2877
        $definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
138✔
2878
        if ($definedNameType === 'Range') {
138✔
2879
            if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
133✔
2880
                $matches2 = Preg::replace(
133✔
2881
                    ['/ +/', '/,/'],
133✔
2882
                    [' ∩ ', ' ∪ '],
133✔
2883
                    trim($matches[2])
133✔
2884
                );
133✔
2885
                $definedNameValue = $matches[1] . $matches2;
133✔
2886
            }
2887
        }
2888
        $definedNameWorksheet = $namedRange->getWorksheet();
138✔
2889

2890
        if ($definedNameValue[0] !== '=') {
138✔
2891
            $definedNameValue = '=' . $definedNameValue;
115✔
2892
        }
2893

2894
        $this->debugLog->writeDebugLog('Defined Name is a %s with a value of %s', $definedNameType, $definedNameValue);
138✔
2895

2896
        $originalCoordinate = $cell->getCoordinate();
138✔
2897
        $recursiveCalculationCell = ($definedNameType !== 'Formula' && $definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
138✔
2898
            ? $definedNameWorksheet->getCell('A1')
20✔
2899
            : $cell;
127✔
2900
        $recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();
138✔
2901

2902
        // Adjust relative references in ranges and formulae so that we execute the calculation for the correct rows and columns
2903
        $definedNameValue = ReferenceHelper::getInstance()
138✔
2904
            ->updateFormulaReferencesAnyWorksheet(
138✔
2905
                $definedNameValue,
138✔
2906
                Coordinate::columnIndexFromString(
138✔
2907
                    $cell->getColumn()
138✔
2908
                ) - 1,
138✔
2909
                $cell->getRow() - 1
138✔
2910
            );
138✔
2911

2912
        $this->debugLog->writeDebugLog('Value adjusted for relative references is %s', $definedNameValue);
138✔
2913

2914
        $recursiveCalculator = new self($this->spreadsheet);
138✔
2915
        $recursiveCalculator->getDebugLog()->setWriteDebugLog($this->getDebugLog()->getWriteDebugLog());
138✔
2916
        $recursiveCalculator->getDebugLog()->setEchoDebugLog($this->getDebugLog()->getEchoDebugLog());
138✔
2917
        $result = $recursiveCalculator->_calculateFormulaValue($definedNameValue, $recursiveCalculationCellAddress, $recursiveCalculationCell, true);
138✔
2918
        $cellWorksheet->getCell($originalCoordinate);
138✔
2919

2920
        if ($this->getDebugLog()->getWriteDebugLog()) {
138✔
UNCOV
2921
            $this->debugLog->mergeDebugLog(array_slice($recursiveCalculator->getDebugLog()->getLog(), 3));
×
UNCOV
2922
            $this->debugLog->writeDebugLog('Evaluation Result for Named %s %s is %s', $definedNameType, $namedRange->getName(), $this->showTypeDetails($result));
×
2923
        }
2924

2925
        $y = $namedRange->getWorksheet()?->getTitle();
138✔
2926
        $x = $namedRange->getLocalOnly();
138✔
2927
        if ($x && $y !== null) {
138✔
2928
            $stack->push('Defined Name', $result, "'$y'!" . $namedRange->getName());
20✔
2929
        } else {
2930
            $stack->push('Defined Name', $result, $namedRange->getName());
119✔
2931
        }
2932

2933
        return $result;
138✔
2934
    }
2935

2936
    public function setSuppressFormulaErrors(bool $suppressFormulaErrors): self
12✔
2937
    {
2938
        $this->suppressFormulaErrors = $suppressFormulaErrors;
12✔
2939

2940
        return $this;
12✔
2941
    }
2942

2943
    public function getSuppressFormulaErrors(): bool
14✔
2944
    {
2945
        return $this->suppressFormulaErrors;
14✔
2946
    }
2947

2948
    public static function boolToString(mixed $operand1): mixed
36✔
2949
    {
2950
        if (is_bool($operand1)) {
36✔
2951
            $operand1 = ($operand1) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
1✔
2952
        } elseif ($operand1 === null) {
36✔
2953
            $operand1 = '';
1✔
2954
        }
2955

2956
        return $operand1;
36✔
2957
    }
2958

2959
    private static function isNumericOrBool(mixed $operand): bool
42✔
2960
    {
2961
        return is_numeric($operand) || is_bool($operand);
42✔
2962
    }
2963

2964
    private static function makeError(mixed $operand = ''): string
3✔
2965
    {
2966
        return (is_string($operand) && Information\ErrorValue::isError($operand)) ? $operand : ExcelError::VALUE();
3✔
2967
    }
2968

2969
    private static function swapOperands(Stack $stack, string $opCharacter): bool
1,919✔
2970
    {
2971
        $retVal = false;
1,919✔
2972
        if ($stack->count() > 0) {
1,919✔
2973
            $o2 = $stack->last();
1,421✔
2974
            if ($o2) {
1,421✔
2975
                /** @var array{value: string} $o2 */
2976
                if (isset(self::CALCULATION_OPERATORS[$o2['value']])) {
1,421✔
2977
                    $retVal = (self::OPERATOR_PRECEDENCE[$opCharacter] ?? 0) <= self::OPERATOR_PRECEDENCE[$o2['value']];
129✔
2978
                }
2979
            }
2980
        }
2981

2982
        return $retVal;
1,919✔
2983
    }
2984

2985
    public function getSpreadsheet(): ?Spreadsheet
2✔
2986
    {
2987
        return $this->spreadsheet;
2✔
2988
    }
2989
}
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