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

PHPOffice / PhpSpreadsheet / 23551431468

25 Mar 2026 04:13PM UTC coverage: 96.961% (+0.06%) from 96.904%
23551431468

Pull #4833

github

web-flow
Merge 972cbe815 into eb391d1f2
Pull Request #4833: Optimize XLS (BIFF8) reader performance with reduced per-cell overhead

74 of 77 new or added lines in 3 files covered. (96.1%)

111 existing lines in 4 files now uncovered.

47796 of 49294 relevant lines covered (96.96%)

384.48 hits per line

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

93.32
/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,310✔
196
    {
197
        $this->spreadsheet = $spreadsheet;
11,310✔
198
        $this->cyclicReferenceStack = new CyclicReferenceStack();
11,310✔
199
        $this->debugLog = new Logger($this->cyclicReferenceStack);
11,310✔
200
        $this->branchPruner = new BranchPruner($this->branchPruningEnabled);
11,310✔
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,641✔
210
    {
211
        if ($spreadsheet !== null) {
13,641✔
212
            return $spreadsheet->getCalculationEngine();
9,826✔
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
122✔
228
    {
229
        if ($spreadsheet !== null) {
122✔
230
            return $spreadsheet->getCalculationEngineOrNull();
82✔
231
        }
232

233
        return null;
117✔
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,243✔
250
    {
251
        return $this->debugLog;
1,243✔
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,351✔
322
    {
323
        return $this->instanceArrayReturnType ?? self::$returnArrayAsType;
9,351✔
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
81✔
373
    {
374
        if (isset($this->calculationCache[$worksheetName])) {
81✔
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,503✔
383
    {
384
        if (isset($this->calculationCache[$fromWorksheetName])) {
1,503✔
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,922✔
417
    {
418
        if (is_string($value)) {
11,922✔
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,491✔
428
            //    Convert numeric errors to NaN error
429
            return ExcelError::NAN();
4✔
430
        }
431

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

435
    /**
436
     * Remove quotes used as a wrapper to identify string values.
437
     */
438
    public static function unwrapResult(mixed $value): mixed
12,163✔
439
    {
440
        if (is_string($value)) {
12,163✔
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,311✔
446
            return ExcelError::NAN();
×
447
        }
448

449
        return $value;
11,539✔
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,502✔
474
    {
475
        if ($cell === null) {
8,502✔
476
            return null;
×
477
        }
478

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

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

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

496
        try {
497
            $value = $cell->getValue();
8,502✔
498
            if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
8,502✔
499
                $value = Preg::replaceCallback(
8,502✔
500
                    self::CALCULATION_REGEXP_CELLREF_SPILL,
8,502✔
501
                    fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
8,502✔
502
                    $value
8,502✔
503
                );
8,502✔
504
            }
505
            $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line
8,502✔
506
            if ($this->spreadsheet === null) {
8,266✔
507
                throw new Exception('null spreadsheet in calculateCellValue');
×
508
            }
509
            $cellAddressAttempted = true;
8,266✔
510
            $cellAddress = array_pop($this->cellStack);
8,266✔
511
            if ($cellAddress === null) {
8,266✔
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,266✔
516
            if ($testSheet === null) {
8,266✔
517
                throw new Exception('worksheet not found in calculateCellValue');
×
518
            }
519
            $testSheet->getCell($cellAddress['cell']);
8,266✔
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,266✔
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,265✔
545
            return 0;
18✔
546
        } elseif ((is_float($result)) && ((is_nan($result)) || (is_infinite($result)))) {
8,263✔
547
            return ExcelError::NAN();
×
548
        }
549

550
        return $result;
8,263✔
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,486✔
561
    {
562
        $formula = Preg::replaceCallback(
8,486✔
563
            self::CALCULATION_REGEXP_CELLREF_SPILL,
8,486✔
564
            fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
8,486✔
565
            $formula
8,486✔
566
        );
8,486✔
567
        //    Basic validation that this is indeed a formula
568
        //    We return an empty array if not
569
        $formula = trim($formula);
8,486✔
570
        if ((!isset($formula[0])) || ($formula[0] != '=')) {
8,486✔
571
            return [];
1✔
572
        }
573
        $formula = ltrim(substr($formula, 1));
8,486✔
574
        if (!isset($formula[0])) {
8,486✔
575
            return [];
2✔
576
        }
577

578
        //    Parse the formula and return the token stack
579
        return $this->internalParseFormula($formula);
8,484✔
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,655✔
622
    {
623
        $this->debugLog->writeDebugLog('Testing cache value for cell %s', $cellReference);
8,655✔
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,655✔
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,655✔
636
    }
637

638
    public function saveValueToCache(string $cellReference, mixed $cellValue): void
8,418✔
639
    {
640
        if ($this->calculationCacheEnabled) {
8,418✔
641
            $this->calculationCache[$cellReference] = $cellValue;
8,408✔
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,388✔
654
    {
655
        $cellValue = null;
12,388✔
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,388✔
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,388✔
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,387✔
670
        if ($formula === '' || $formula[0] !== '=') {
12,387✔
671
            return self::wrapResult($formula);
2✔
672
        }
673
        $formula = ltrim(substr($formula, 1));
12,387✔
674
        if (!isset($formula[0])) {
12,387✔
675
            return self::wrapResult($formula);
6✔
676
        }
677

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

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

687
        if (($wsTitle[0] !== "\x00") && ($this->cyclicReferenceStack->onStack($wsCellReference))) {
12,386✔
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,386✔
708
        //    Parse the formula onto the token stack and calculate the value
709
        $this->cyclicReferenceStack->push($wsCellReference);
12,386✔
710

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

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

719
        //    Return the calculated value
720
        return $cellValue;
12,147✔
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,123✔
908
    {
909
        if ($this->debugLog->getWriteDebugLog()) {
12,123✔
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,123✔
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,142✔
946
    {
947
        if ($this->debugLog->getWriteDebugLog()) {
12,142✔
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,139✔
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,538✔
986
    {
987
        //    Convert any Excel matrix references to the MKMATRIX() function
988
        if (str_contains($formula, self::FORMULA_OPEN_MATRIX_BRACE)) {
12,538✔
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,537✔
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
6✔
1060
    {
1061
        $matches5 = (string) $matches[5];
6✔
1062
        // Weirdly, the regexp which get us here for issue 4832
1063
        // only gets us here for Php8.4+. 8.4 introduced
1064
        // major changes for PCRE, but I cannot identify
1065
        // the exact change which caused this discrepancy.
1066
        // I do plan to update coverage to 8.4 at some point,
1067
        // and I can remove the coverage annotations after that.
1068
        // @codeCoverageIgnoreStart
1069
        if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
1070
            if ($this->spreadsheet !== null) {
1071
                if ($this->spreadsheet->getSheetByName($matches5) === null) {
1072
                    $matches0 = (string) $matches[0];
1073
                    $this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
1074

1075
                    return $matches0;
1076
                }
1077
            }
1078
        }
1079
        // @codeCoverageIgnoreEnd
1080
        $matches1 = (string) $matches[1];
6✔
1081
        $matches2 = (string) $matches[2];
6✔
1082

1083
        return $matches1 . str_replace(',', '∪', $matches2);
6✔
1084
    }
1085

1086
    private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
1087
        = '(?:'
1088
        . self::CALCULATION_REGEXP_CELLREF // cell address
1089
        . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
1090
        . '|' . self::CALCULATION_REGEXP_DEFINEDNAME
1091
        . ')'
1092
        ;
1093

1094
    public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
1095
        . '([(]' // open paren
1096
        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1097
        . '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
1098
        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1099
        . ')+' // one or more occurrences
1100
        . '\s*[)])/i'; // optional whitespace, end paren
1101

1102
    /**
1103
     * @return array<int, mixed>|false
1104
     */
1105
    private function internalParseFormula(string $formula, ?Cell $cell = null): bool|array
12,538✔
1106
    {
1107
        if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
12,538✔
UNCOV
1108
            return false;
×
1109
        }
1110

1111
        $oldFormula = $formula;
12,537✔
1112
        $formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, $this->unionForComma(...), $formula);
12,537✔
1113
        if ($oldFormula !== $formula) {
12,537✔
1114
            $this->debugLog->writeDebugLog('Reformulated as %s', $formula);
6✔
1115
        }
1116
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,537✔
1117

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

1122
        $regexpMatchString = '/^((?<string>' . self::CALCULATION_REGEXP_STRING
12,537✔
1123
                                . ')|(?<function>' . self::CALCULATION_REGEXP_FUNCTION
12,537✔
1124
                                . ')|(?<cellRef>' . self::CALCULATION_REGEXP_CELLREF
12,537✔
1125
                                . ')|(?<colRange>' . self::CALCULATION_REGEXP_COLUMN_RANGE
12,537✔
1126
                                . ')|(?<rowRange>' . self::CALCULATION_REGEXP_ROW_RANGE
12,537✔
1127
                                . ')|(?<number>' . self::CALCULATION_REGEXP_NUMBER
12,537✔
1128
                                . ')|(?<openBrace>' . self::CALCULATION_REGEXP_OPENBRACE
12,537✔
1129
                                . ')|(?<structuredReference>' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE
12,537✔
1130
                                . ')|(?<definedName>' . self::CALCULATION_REGEXP_DEFINEDNAME
12,537✔
1131
                                . ')|(?<error>' . self::CALCULATION_REGEXP_ERROR
12,537✔
1132
                                . '))/sui';
12,537✔
1133

1134
        //    Start with initialisation
1135
        $index = 0;
12,537✔
1136
        $stack = new Stack($this->branchPruner);
12,537✔
1137
        $output = [];
12,537✔
1138
        $expectingOperator = false; //    We use this test in syntax-checking the expression to determine when a
12,537✔
1139
        //        - is a negation or + is a positive operator rather than an operation
1140
        $expectingOperand = false; //    We use this test in syntax-checking the expression to determine whether an operand
12,537✔
1141
        //        should be null in a function call
1142

1143
        //    The guts of the lexical parser
1144
        //    Loop through the formula extracting each operator and operand in turn
1145
        while (true) {
12,537✔
1146
            // Branch pruning: we adapt the output item to the context (it will
1147
            // be used to limit its computation)
1148
            $this->branchPruner->initialiseForLoop();
12,537✔
1149

1150
            $opCharacter = $formula[$index]; //    Get the first character of the value at the current index position
12,537✔
1151
            if ($opCharacter === "\xe2") { // intersection or union
12,537✔
1152
                $opCharacter .= $formula[++$index];
9✔
1153
                $opCharacter .= $formula[++$index];
9✔
1154
            }
1155

1156
            // Check for two-character operators (e.g. >=, <=, <>)
1157
            if ((isset(self::COMPARISON_OPERATORS[$opCharacter])) && (strlen($formula) > $index) && isset($formula[$index + 1], self::COMPARISON_OPERATORS[$formula[$index + 1]])) {
12,537✔
1158
                $opCharacter .= $formula[++$index];
85✔
1159
            }
1160
            //    Find out if we're currently at the beginning of a number, variable, cell/row/column reference,
1161
            //         function, defined name, structured reference, parenthesis, error or operand
1162
            $isOperandOrFunction = (bool) preg_match($regexpMatchString, substr($formula, $index), $match);
12,537✔
1163

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

1183
                //    Finally put our current operator onto the stack
1184
                $stack->push('Binary Operator', $opCharacter);
1,918✔
1185

1186
                ++$index;
1,918✔
1187
                $expectingOperator = false;
1,918✔
1188
            } elseif ($opCharacter === ')' && $expectingOperator) { //    Are we expecting to close a parenthesis?
12,537✔
1189
                $expectingOperand = false;
12,140✔
1190
                while (($o2 = $stack->pop()) && $o2['value'] !== '(') { //    Pop off the stack back to the last (
12,140✔
1191
                    $output[] = $o2;
1,439✔
1192
                }
1193
                $d = $stack->last(2);
12,140✔
1194

1195
                // Branch pruning we decrease the depth whether is it a function
1196
                // call or a parenthesis
1197
                $this->branchPruner->decrementDepth();
12,140✔
1198

1199
                if (is_array($d) && preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($d['value']), $matches)) {
12,140✔
1200
                    //    Did this parenthesis just close a function?
1201
                    try {
1202
                        $this->branchPruner->closingBrace($d['value']);
12,134✔
1203
                    } catch (Exception $e) {
4✔
1204
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
4✔
1205
                    }
1206

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

1245
                                break;
1,210✔
1246
                            case '-':
5,174✔
1247
                                if (($argumentCount < $argMatch[1]) || ($argumentCount > $argMatch[3])) {
931✔
1248
                                    $argumentCountError = true;
15✔
1249
                                    $expectedArgumentCountString = 'between ' . $argMatch[1] . ' and ' . $argMatch[3];
15✔
1250
                                }
1251

1252
                                break;
931✔
1253
                            case ',':
4,285✔
1254
                                if (($argumentCount != $argMatch[1]) && ($argumentCount != $argMatch[3])) {
4,285✔
1255
                                    $argumentCountError = true;
39✔
1256
                                    $expectedArgumentCountString = 'either ' . $argMatch[1] . ' or ' . $argMatch[3];
39✔
1257
                                }
1258

1259
                                break;
4,285✔
1260
                        }
1261
                    }
1262
                    if ($argumentCountError) {
12,134✔
1263
                        /** @var int $argumentCount */
1264
                        return $this->raiseFormulaError("Formula Error: Wrong number of arguments for $functionName() function: $argumentCount given, " . $expectedArgumentCountString . ' expected');
229✔
1265
                    }
1266
                }
1267
                ++$index;
11,914✔
1268
            } elseif ($opCharacter === ',') { // Is this the separator for function arguments?
12,537✔
1269
                try {
1270
                    $this->branchPruner->argumentSeparator();
8,198✔
UNCOV
1271
                } catch (Exception $e) {
×
UNCOV
1272
                    return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1273
                }
1274

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

1294
                    $stack->push('Binary Operator', '∪');
1✔
1295

1296
                    ++$index;
1✔
1297
                    $expectingOperator = false;
1✔
1298

1299
                    continue;
1✔
1300
                }
1301

1302
                /** @var array<string, int> $d */
1303
                $d = $stack->pop();
8,197✔
1304
                ++$d['value']; // increment the argument count
8,197✔
1305

1306
                $stack->pushStackItem($d);
8,197✔
1307
                $stack->push('Brace', '('); // put the ( back on, we'll need to pop back to it again
8,197✔
1308

1309
                $expectingOperator = false;
8,197✔
1310
                $expectingOperand = true;
8,197✔
1311
                ++$index;
8,197✔
1312
            } elseif ($opCharacter === '(' && !$expectingOperator) {
12,537✔
1313
                // Branch pruning: we go deeper
1314
                $this->branchPruner->incrementDepth();
44✔
1315
                $stack->push('Brace', '(', null);
44✔
1316
                ++$index;
44✔
1317
            } elseif ($isOperandOrFunction && !$expectingOperatorCopy) {
12,537✔
1318
                // do we now have a function/variable/number?
1319
                $expectingOperator = true;
12,533✔
1320
                $expectingOperand = false;
12,533✔
1321
                $val = $match[1] ?? ''; //* @phpstan-ignore-line
12,533✔
1322
                $length = strlen($val);
12,533✔
1323

1324
                if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
12,533✔
1325
                    // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
1326
                    $val = Preg::replace('/\s/u', '', $val);
12,141✔
1327
                    if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) {    // it's a function
12,141✔
1328
                        $valToUpper = strtoupper($val);
12,139✔
1329
                    } else {
1330
                        $valToUpper = 'NAME.ERROR(';
6✔
1331
                    }
1332
                    // here $matches[1] will contain values like "IF"
1333
                    // and $val "IF("
1334

1335
                    $this->branchPruner->functionCall($valToUpper);
12,141✔
1336

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

1392
                    $output[] = $outputItem;
7,347✔
1393
                } elseif (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '$/miu', $val, $matches)) {
6,506✔
1394
                    try {
1395
                        $structuredReference = Operands\StructuredReference::fromParser($formula, $index, $matches);
76✔
UNCOV
1396
                    } catch (Exception $e) {
×
UNCOV
1397
                        return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
×
1398
                    }
1399

1400
                    $val = $structuredReference->value();
76✔
1401
                    $length = strlen($val);
76✔
1402
                    $outputItem = $stack->getStackItem(Operands\StructuredReference::NAME, $structuredReference, null);
76✔
1403

1404
                    $output[] = $outputItem;
76✔
1405
                    $expectingOperator = true;
76✔
1406
                } else {
1407
                    // it's a variable, constant, string, number or boolean
1408
                    $localeConstant = false;
6,439✔
1409
                    $stackItemType = 'Value';
6,439✔
1410
                    $stackItemReference = null;
6,439✔
1411

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

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

1460
                            $refSheet = $pCellParent;
32✔
1461
                            if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) {
32✔
1462
                                $refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
4✔
1463
                            }
1464

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

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

1560
                break;
12,301✔
1561
            }
1562
            //    Ignore white space
1563
            while (($formula[$index] === "\n") || ($formula[$index] === "\r")) {
12,500✔
UNCOV
1564
                ++$index;
×
1565
            }
1566

1567
            if ($formula[$index] === ' ') {
12,500✔
1568
                while ($formula[$index] === ' ') {
2,134✔
1569
                    ++$index;
2,134✔
1570
                }
1571

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

1598
        while (($op = $stack->pop()) !== null) {
12,301✔
1599
            // pop everything off the stack and push onto output
1600
            if ($op['value'] == '(') {
774✔
1601
                return $this->raiseFormulaError("Formula Error: Expecting ')'"); // if there are any opening braces on the stack, then braces were unbalanced
5✔
1602
            }
1603
            $output[] = $op;
771✔
1604
        }
1605

1606
        return $output;
12,298✔
1607
    }
1608

1609
    /** @param mixed[] $operandData */
1610
    private static function dataTestReference(array &$operandData): mixed
1,793✔
1611
    {
1612
        $operand = $operandData['value'];
1,793✔
1613
        if (($operandData['reference'] === null) && (is_array($operand))) {
1,793✔
1614
            $rKeys = array_keys($operand);
47✔
1615
            $rowKey = array_shift($rKeys);
47✔
1616
            if (is_array($operand[$rowKey]) === false) {
47✔
1617
                $operandData['value'] = $operand[$rowKey];
7✔
1618

1619
                return $operand[$rowKey];
7✔
1620
            }
1621

1622
            $cKeys = array_keys(array_keys($operand[$rowKey]));
44✔
1623
            $colKey = array_shift($cKeys);
44✔
1624
            if (ctype_upper("$colKey")) {
44✔
UNCOV
1625
                $operandData['reference'] = $colKey . $rowKey;
×
1626
            }
1627
        }
1628

1629
        return $operand;
1,793✔
1630
    }
1631

1632
    private static int $matchIndex8 = 8;
1633

1634
    private static int $matchIndex9 = 9;
1635

1636
    private static int $matchIndex10 = 10;
1637

1638
    /**
1639
     * @param array<mixed>|false $tokens
1640
     *
1641
     * @return array<int, mixed>|false|string
1642
     */
1643
    private function processTokenStack(false|array $tokens, ?string $cellID = null, ?Cell $cell = null)
12,155✔
1644
    {
1645
        if ($tokens === false) {
12,155✔
1646
            return false;
2✔
1647
        }
1648
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
12,154✔
1649

1650
        //    If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent cell collection),
1651
        //        so we store the parent cell collection so that we can re-attach it when necessary
1652
        $pCellWorksheet = ($cell !== null) ? $cell->getWorksheet() : null;
12,154✔
1653
        $originalCoordinate = $cell?->getCoordinate();
12,154✔
1654
        $pCellParent = ($cell !== null) ? $cell->getParent() : null;
12,154✔
1655
        $stack = new Stack($this->branchPruner);
12,154✔
1656

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

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

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

1703
                    continue;
61✔
1704
                }
1705
            }
1706

1707
            if ($this->branchPruningEnabled && isset($tokenData['onlyIfNot'])) {
12,154✔
1708
                /** @var string */
1709
                $onlyIfNotStoreKey = $tokenData['onlyIfNot'];
79✔
1710
                $storeValue = $branchStore[$onlyIfNotStoreKey] ?? null;
79✔
1711
                $storeValueAsBool = ($storeValue === null)
79✔
1712
                    ? true : (bool) Functions::flattenSingleValue($storeValue);
79✔
1713
                if (is_array($storeValue)) {
79✔
1714
                    $wrappedItem = end($storeValue);
52✔
1715
                    $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
52✔
1716
                }
1717

1718
                if (
1719
                    (isset($storeValue) || $tokenData['reference'] === 'NULL')
79✔
1720
                    && ($storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
79✔
1721
                ) {
1722
                    // If branching value is true, we don't need to compute
1723
                    if (!isset($fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey])) {
57✔
1724
                        /** @var string $token */
1725
                        $stack->push('Value', 'Pruned branch (only if not ' . $onlyIfNotStoreKey . ') ' . $token);
57✔
1726
                        $fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey] = true;
57✔
1727
                    }
1728

1729
                    if (isset($storeKey)) {
57✔
1730
                        // We are processing an if condition
1731
                        // We cascade the pruning to the depending branches
1732
                        $branchStore[$storeKey] = 'Pruned branch';
10✔
1733
                        $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
10✔
1734
                        $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
10✔
1735
                    }
1736

1737
                    continue;
57✔
1738
                }
1739
            }
1740

1741
            if ($token instanceof Operands\StructuredReference) {
12,154✔
1742
                if ($cell === null) {
17✔
UNCOV
1743
                    return $this->raiseFormulaError('Structured References must exist in a Cell context');
×
1744
                }
1745

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

1779
                $operand1 = self::dataTestReference($operand1Data);
1,793✔
1780
                $operand2 = self::dataTestReference($operand2Data);
1,793✔
1781

1782
                //    Log what we're doing
1783
                if ($token == ':') {
1,793✔
1784
                    $this->debugLog->writeDebugLog('Evaluating Range %s %s %s', $this->showValue($operand1Data['reference']), $token, $this->showValue($operand2Data['reference']));
1,298✔
1785
                } else {
1786
                    $this->debugLog->writeDebugLog('Evaluating %s %s %s', $this->showValue($operand1), $token, $this->showValue($operand2));
777✔
1787
                }
1788

1789
                //    Process the operation in the appropriate manner
1790
                switch ($token) {
1791
                    // Comparison (Boolean) Operators
1792
                    case '>': // Greater than
1,793✔
1793
                    case '<': // Less than
1,778✔
1794
                    case '>=': // Greater than or Equal to
1,761✔
1795
                    case '<=': // Less than or Equal to
1,753✔
1796
                    case '=': // Equality
1,735✔
1797
                    case '<>': // Inequality
1,582✔
1798
                        $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack);
414✔
1799
                        if (isset($storeKey)) {
414✔
1800
                            $branchStore[$storeKey] = $result;
71✔
1801
                        }
1802

1803
                        break;
414✔
1804
                    // Binary Operators
1805
                    case ':': // Range
1,572✔
1806
                        if ($operand1Data['type'] === 'Error') {
1,298✔
1807
                            $stack->push($operand1Data['type'], $operand1Data['value'], null);
6✔
1808

1809
                            break;
6✔
1810
                        }
1811
                        if ($operand2Data['type'] === 'Error') {
1,293✔
1812
                            $stack->push($operand2Data['type'], $operand2Data['value'], null);
4✔
1813

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

1834
                        /** @var string */
1835
                        $op2ref = $operand2Data['reference'];
1,289✔
1836
                        [$sheet2, $operand2Data['reference']] = Worksheet::extractSheetTitle($op2ref, true, true);
1,289✔
1837
                        if (empty($sheet2)) {
1,289✔
1838
                            $sheet2 = $sheet1;
7✔
1839
                        }
1840

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

1867
                            $oData = array_merge(explode(':', $operand1Data['reference'] ?? ''), explode(':', $operand2Data['reference'] ?? ''));
1,286✔
1868
                            $oCol = $oRow = [];
1,286✔
1869
                            $breakNeeded = false;
1,286✔
1870
                            foreach ($oData as $oDatum) {
1,286✔
1871
                                try {
1872
                                    $oCR = Coordinate::coordinateFromString($oDatum);
1,286✔
1873
                                    $oCol[] = Coordinate::columnIndexFromString($oCR[0]) - 1;
1,286✔
1874
                                    $oRow[] = $oCR[1];
1,286✔
1875
                                } catch (\Exception) {
1✔
1876
                                    $stack->push('Error', ExcelError::REF(), null);
1✔
1877
                                    $breakNeeded = true;
1✔
1878

1879
                                    break;
1✔
1880
                                }
1881
                            }
1882
                            if ($breakNeeded) {
1,286✔
1883
                                break;
1✔
1884
                            }
1885
                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
1,285✔
1886
                            if ($pCellParent !== null && $this->spreadsheet !== null) {
1,285✔
1887
                                $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
1,285✔
1888
                            } else {
UNCOV
1889
                                return $this->raiseFormulaError('Unable to access Cell Reference');
×
1890
                            }
1891

1892
                            $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellValue));
1,285✔
1893
                            $stack->push('Cell Reference', $cellValue, $cellRef);
1,285✔
1894
                        } else {
1895
                            $this->debugLog->writeDebugLog('Evaluation Result is a #REF! Error');
4✔
1896
                            $stack->push('Error', ExcelError::REF(), null);
4✔
1897
                        }
1898

1899
                        break;
1,288✔
1900
                    case '+':            //    Addition
432✔
1901
                    case '-':            //    Subtraction
344✔
1902
                    case '*':            //    Multiplication
303✔
1903
                    case '/':            //    Division
173✔
1904
                    case '^':            //    Exponential
58✔
1905
                        $result = $this->executeNumericBinaryOperation($operand1, $operand2, $token, $stack);
392✔
1906
                        if (isset($storeKey)) {
392✔
1907
                            $branchStore[$storeKey] = $result;
6✔
1908
                        }
1909

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

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

1968
                        if (isset($storeKey)) {
29✔
UNCOV
1969
                            $branchStore[$storeKey] = $result;
×
1970
                        }
1971

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

1995
                        break;
18✔
1996
                    case '∪':            //    union
10✔
1997
                        /** @var mixed[][] $operand1 */
1998
                        /** @var mixed[][] $operand2 */
1999
                        $cellUnion = array_merge($operand1, $operand2);
10✔
2000
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellUnion));
10✔
2001
                        $stack->push('Value', $cellUnion, 'A1');
10✔
2002

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

2035
                    $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
4✔
2036
                    $stack->push('Value', $result);
4✔
2037
                    if (isset($storeKey)) {
4✔
UNCOV
2038
                        $branchStore[$storeKey] = $result;
×
2039
                    }
2040
                } else {
2041
                    $this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
1,168✔
2042
                }
2043
            } elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
12,146✔
2044
                $cellRef = null;
7,235✔
2045

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

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

2156
                $functionName = $matches[1];
11,851✔
2157
                /** @var array<string, int> $argCount */
2158
                $argCount = $stack->pop();
11,851✔
2159
                $argCount = $argCount['value'];
11,851✔
2160
                if ($functionName !== 'MKMATRIX') {
11,851✔
2161
                    $this->debugLog->writeDebugLog('Evaluating Function %s() with %s argument%s', self::localeFunc($functionName), (($argCount == 0) ? 'no' : $argCount), (($argCount == 1) ? '' : 's'));
11,850✔
2162
                }
2163
                if ((isset($phpSpreadsheetFunctions[$functionName])) || (isset(self::$controlFunctions[$functionName]))) {    // function
11,851✔
2164
                    $passByReference = false;
11,851✔
2165
                    $passCellReference = false;
11,851✔
2166
                    $functionCall = null;
11,851✔
2167
                    if (isset($phpSpreadsheetFunctions[$functionName])) {
11,851✔
2168
                        $functionCall = $phpSpreadsheetFunctions[$functionName]['functionCall'];
11,848✔
2169
                        $passByReference = isset($phpSpreadsheetFunctions[$functionName]['passByReference']);
11,848✔
2170
                        $passCellReference = isset($phpSpreadsheetFunctions[$functionName]['passCellReference']);
11,848✔
2171
                    } elseif (isset(self::$controlFunctions[$functionName])) {
844✔
2172
                        $functionCall = self::$controlFunctions[$functionName]['functionCall'];
844✔
2173
                        $passByReference = isset(self::$controlFunctions[$functionName]['passByReference']);
844✔
2174
                        $passCellReference = isset(self::$controlFunctions[$functionName]['passCellReference']);
844✔
2175
                    }
2176

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

2234
                    //    Reverse the order of the arguments
2235
                    krsort($args);
11,851✔
2236
                    krsort($emptyArguments);
11,851✔
2237

2238
                    if ($argCount > 0 && is_array($functionCall)) {
11,851✔
2239
                        /** @var string[] */
2240
                        $functionCallCopy = $functionCall;
11,832✔
2241
                        $args = $this->addDefaultArgumentValues($functionCallCopy, $args, $emptyArguments);
11,832✔
2242
                    }
2243

2244
                    if (($passByReference) && ($argCount == 0)) {
11,851✔
2245
                        $args[] = $cellID;
9✔
2246
                        $argArrayVals[] = $this->showValue($cellID);
9✔
2247
                    }
2248

2249
                    if ($functionName !== 'MKMATRIX') {
11,851✔
2250
                        if ($this->debugLog->getWriteDebugLog()) {
11,850✔
2251
                            krsort($argArrayVals);
2✔
2252
                            $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
2✔
2253
                        }
2254
                    }
2255

2256
                    //    Process the argument with the appropriate function call
2257
                    if ($pCellWorksheet !== null && $originalCoordinate !== null) {
11,851✔
2258
                        $pCellWorksheet->getCell($originalCoordinate);
8,260✔
2259
                    }
2260
                    /** @var array<string>|string $functionCall */
2261
                    $args = $this->addCellReference($args, $passCellReference, $functionCall, $cell);
11,851✔
2262

2263
                    if (!is_array($functionCall)) {
11,851✔
2264
                        foreach ($args as &$arg) {
54✔
2265
                            $arg = Functions::flattenSingleValue($arg);
1✔
2266
                        }
2267
                        unset($arg);
54✔
2268
                    }
2269

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

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

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

2360
        return $output;
12,145✔
2361
    }
2362

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

2386
                    return false;
6✔
2387
                } elseif (Engine\FormattedNumber::convertToNumberIfFormatted($operand) === false) {
11✔
2388
                    //    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
2389
                    $stack->push('Error', '#VALUE!');
6✔
2390
                    $this->debugLog->writeDebugLog('Evaluation Result is a %s', $this->showTypeDetails('#VALUE!'));
6✔
2391

2392
                    return false;
6✔
2393
                }
2394
            }
2395
        }
2396

2397
        //    return a true if the value of the operand is one that we can use in normal binary mathematical operations
2398
        return true;
1,491✔
2399
    }
2400

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

2443
        return $result;
56✔
2444
    }
2445

2446
    /** @return array<mixed>|bool|string */
2447
    private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
414✔
2448
    {
2449
        //    If we're dealing with matrix operations, we want a matrix result
2450
        if ((is_array($operand1)) || (is_array($operand2))) {
414✔
2451
            return $this->executeArrayComparison($operand1, $operand2, $operation, $stack, $recursingArrays);
56✔
2452
        }
2453

2454
        $result = BinaryComparison::compare($operand1, $operand2, $operation);
414✔
2455

2456
        //    Log the result details
2457
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
414✔
2458
        //    And push the result onto the stack
2459
        $stack->push('Value', $result);
414✔
2460

2461
        return $result;
414✔
2462
    }
2463

2464
    private function executeNumericBinaryOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack): mixed
1,493✔
2465
    {
2466
        //    Validate the two operands
2467
        if (
2468
            ($this->validateBinaryOperand($operand1, $stack) === false)
1,493✔
2469
            || ($this->validateBinaryOperand($operand2, $stack) === false)
1,493✔
2470
        ) {
2471
            return false;
10✔
2472
        }
2473

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

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

2502
                        continue;
1✔
2503
                    }
2504
                    /** @var mixed[][] $operand2 */
2505
                    if (($operand2[$row][$column] ?? null) === null) {
38✔
2506
                        $operand2[$row][$column] = 0;
2✔
2507
                    } elseif (!self::isNumericOrBool($operand2[$row][$column])) {
38✔
UNCOV
2508
                        $operand1[$row][$column] = self::makeError($operand2[$row][$column]);
×
2509

UNCOV
2510
                        continue;
×
2511
                    }
2512
                    /** @var float|int */
2513
                    $operand1Val = $operand1[$row][$column];
38✔
2514
                    /** @var float|int */
2515
                    $operand2Val = $operand2[$row][$column];
38✔
2516
                    switch ($operation) {
2517
                        case '+':
38✔
2518
                            $operand1[$row][$column] = $operand1Val + $operand2Val;
4✔
2519

2520
                            break;
4✔
2521
                        case '-':
35✔
2522
                            $operand1[$row][$column] = $operand1Val - $operand2Val;
4✔
2523

2524
                            break;
4✔
2525
                        case '*':
32✔
2526
                            $operand1[$row][$column] = $operand1Val * $operand2Val;
25✔
2527

2528
                            break;
25✔
2529
                        case '/':
7✔
2530
                            if ($operand2Val == 0) {
5✔
2531
                                $operand1[$row][$column] = ExcelError::DIV0();
3✔
2532
                            } else {
2533
                                $operand1[$row][$column] = $operand1Val / $operand2Val;
4✔
2534
                            }
2535

2536
                            break;
5✔
2537
                        case '^':
2✔
2538
                            $operand1[$row][$column] = $operand1Val ** $operand2Val;
2✔
2539

2540
                            break;
2✔
2541

2542
                        default:
UNCOV
2543
                            throw new Exception('Unsupported numeric binary operation');
×
2544
                    }
2545
                }
2546
            }
2547
            $result = $operand1;
38✔
2548
        } else {
2549
            //    If we're dealing with non-matrix operations, execute the necessary operation
2550
            /** @var float|int $operand1 */
2551
            /** @var float|int $operand2 */
2552
            switch ($operation) {
2553
                //    Addition
2554
                case '+':
1,468✔
2555
                    $result = $operand1 + $operand2;
170✔
2556

2557
                    break;
170✔
2558
                //    Subtraction
2559
                case '-':
1,380✔
2560
                    $result = $operand1 - $operand2;
52✔
2561

2562
                    break;
52✔
2563
                //    Multiplication
2564
                case '*':
1,344✔
2565
                    $result = $operand1 * $operand2;
1,266✔
2566

2567
                    break;
1,266✔
2568
                //    Division
2569
                case '/':
115✔
2570
                    if ($operand2 == 0) {
113✔
2571
                        //    Trap for Divide by Zero error
2572
                        $stack->push('Error', ExcelError::DIV0());
62✔
2573
                        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails(ExcelError::DIV0()));
62✔
2574

2575
                        return false;
62✔
2576
                    }
2577
                    $result = $operand1 / $operand2;
61✔
2578

2579
                    break;
61✔
2580
                //    Power
2581
                case '^':
3✔
2582
                    $result = $operand1 ** $operand2;
3✔
2583

2584
                    break;
3✔
2585

2586
                default:
UNCOV
2587
                    throw new Exception('Unsupported numeric binary operation');
×
2588
            }
2589
        }
2590

2591
        //    Log the result details
2592
        $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
1,442✔
2593
        //    And push the result onto the stack
2594
        $stack->push('Value', $result);
1,442✔
2595

2596
        return $result;
1,442✔
2597
    }
2598

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

2615
        return false;
2✔
2616
    }
2617

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

2633
        if ($worksheet !== null) {
7,268✔
2634
            $worksheetName = $worksheet->getTitle();
7,267✔
2635

2636
            if (str_contains($range, '!')) {
7,267✔
2637
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
10✔
2638
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
10✔
2639
            }
2640

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

2690
        return $returnValue;
7,268✔
2691
    }
2692

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

2707
        if ($worksheet !== null) {
1✔
2708
            if (str_contains($range, '!')) {
1✔
2709
                [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
1✔
2710
                $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
1✔
2711
            }
2712

2713
            // Named range?
2714
            $namedRange = ($worksheet === null) ? null : DefinedName::resolveName($range, $worksheet);
1✔
2715
            if ($namedRange === null) {
1✔
2716
                return ExcelError::REF();
1✔
2717
            }
2718

2719
            $worksheet = $namedRange->getWorksheet();
1✔
2720
            $range = $namedRange->getValue();
1✔
2721
            $splitRange = Coordinate::splitRange($range);
1✔
2722
            //    Convert row and column references
2723
            if ($worksheet !== null && ctype_alpha($splitRange[0][0])) {
1✔
UNCOV
2724
                $range = $splitRange[0][0] . '1:' . $splitRange[0][1] . $worksheet->getHighestRow();
×
2725
            } elseif ($worksheet !== null && ctype_digit($splitRange[0][0])) {
1✔
UNCOV
2726
                $range = 'A' . $splitRange[0][0] . ':' . $worksheet->getHighestColumn() . $splitRange[0][1];
×
2727
            }
2728

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

2754
        return $returnValue;
1✔
2755
    }
2756

2757
    /**
2758
     * Is a specific function implemented?
2759
     *
2760
     * @param string $function Function Name
2761
     */
2762
    public function isImplemented(string $function): bool
3✔
2763
    {
2764
        $function = strtoupper($function);
3✔
2765
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
3✔
2766
        $notImplemented = !isset($phpSpreadsheetFunctions[$function]) || (is_array($phpSpreadsheetFunctions[$function]['functionCall']) && $phpSpreadsheetFunctions[$function]['functionCall'][1] === 'DUMMY');
3✔
2767

2768
        return !$notImplemented;
3✔
2769
    }
2770

2771
    /**
2772
     * Get a list of implemented Excel function names.
2773
     *
2774
     * @return string[]
2775
     */
2776
    public function getImplementedFunctionNames(): array
2✔
2777
    {
2778
        $returnValue = [];
2✔
2779
        $phpSpreadsheetFunctions = &self::getFunctionsAddress();
2✔
2780
        foreach ($phpSpreadsheetFunctions as $functionName => $function) {
2✔
2781
            if ($this->isImplemented($functionName)) {
2✔
2782
                $returnValue[] = $functionName;
2✔
2783
            }
2784
        }
2785

2786
        return $returnValue;
2✔
2787
    }
2788

2789
    /**
2790
     * @param string[] $functionCall
2791
     * @param mixed[] $args
2792
     * @param mixed[] $emptyArguments
2793
     *
2794
     * @return mixed[]
2795
     */
2796
    private function addDefaultArgumentValues(array $functionCall, array $args, array $emptyArguments): array
11,832✔
2797
    {
2798
        $reflector = new ReflectionMethod($functionCall[0], $functionCall[1]);
11,832✔
2799
        $methodArguments = $reflector->getParameters();
11,832✔
2800

2801
        if (count($methodArguments) > 0) {
11,832✔
2802
            // Apply any defaults for empty argument values
2803
            foreach ($emptyArguments as $argumentId => $isArgumentEmpty) {
11,832✔
2804
                if ($isArgumentEmpty === true) {
11,766✔
2805
                    $reflectedArgumentId = count($args) - (int) $argumentId - 1;
148✔
2806
                    if (
2807
                        !array_key_exists($reflectedArgumentId, $methodArguments)
148✔
2808
                        || $methodArguments[$reflectedArgumentId]->isVariadic()
148✔
2809
                    ) {
2810
                        break;
13✔
2811
                    }
2812

2813
                    $args[$argumentId] = $this->getArgumentDefaultValue($methodArguments[$reflectedArgumentId]);
135✔
2814
                }
2815
            }
2816
        }
2817

2818
        return $args;
11,832✔
2819
    }
2820

2821
    private function getArgumentDefaultValue(ReflectionParameter $methodArgument): mixed
135✔
2822
    {
2823
        $defaultValue = null;
135✔
2824

2825
        if ($methodArgument->isDefaultValueAvailable()) {
135✔
2826
            $defaultValue = $methodArgument->getDefaultValue();
63✔
2827
            if ($methodArgument->isDefaultValueConstant()) {
63✔
2828
                $constantName = $methodArgument->getDefaultValueConstantName() ?? '';
2✔
2829
                // read constant value
2830
                if (str_contains($constantName, '::')) {
2✔
2831
                    [$className, $constantName] = explode('::', $constantName);
2✔
2832
                    /** @var class-string $className */
2833
                    $constantReflector = new ReflectionClassConstant($className, $constantName);
2✔
2834

2835
                    return $constantReflector->getValue();
2✔
2836
                }
2837

UNCOV
2838
                return constant($constantName);
×
2839
            }
2840
        }
2841

2842
        return $defaultValue;
134✔
2843
    }
2844

2845
    /**
2846
     * Add cell reference if needed while making sure that it is the last argument.
2847
     *
2848
     * @param mixed[] $args
2849
     * @param string|string[] $functionCall
2850
     *
2851
     * @return mixed[]
2852
     */
2853
    private function addCellReference(array $args, bool $passCellReference, array|string $functionCall, ?Cell $cell = null): array
11,851✔
2854
    {
2855
        if ($passCellReference) {
11,851✔
2856
            if (is_array($functionCall)) {
313✔
2857
                $className = $functionCall[0];
313✔
2858
                $methodName = $functionCall[1];
313✔
2859

2860
                $reflectionMethod = new ReflectionMethod($className, $methodName);
313✔
2861
                $argumentCount = count($reflectionMethod->getParameters());
313✔
2862
                while (count($args) < $argumentCount - 1) {
313✔
2863
                    $args[] = null;
57✔
2864
                }
2865
            }
2866

2867
            $args[] = $cell;
313✔
2868
        }
2869

2870
        return $args;
11,851✔
2871
    }
2872

2873
    private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksheet $cellWorksheet, Stack $stack, bool $ignoreScope = false): mixed
138✔
2874
    {
2875
        $definedNameScope = $namedRange->getScope();
138✔
2876
        if ($definedNameScope !== null && $definedNameScope !== $cellWorksheet && !$ignoreScope) {
138✔
2877
            // The defined name isn't in our current scope, so #REF
UNCOV
2878
            $result = ExcelError::REF();
×
UNCOV
2879
            $stack->push('Error', $result, $namedRange->getName());
×
2880

UNCOV
2881
            return $result;
×
2882
        }
2883

2884
        $definedNameValue = $namedRange->getValue();
138✔
2885
        $definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
138✔
2886
        if ($definedNameType === 'Range') {
138✔
2887
            if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
133✔
2888
                $matches2 = Preg::replace(
133✔
2889
                    ['/ +/', '/,/'],
133✔
2890
                    [' ∩ ', ' ∪ '],
133✔
2891
                    trim($matches[2])
133✔
2892
                );
133✔
2893
                $definedNameValue = $matches[1] . $matches2;
133✔
2894
            }
2895
        }
2896
        $definedNameWorksheet = $namedRange->getWorksheet();
138✔
2897

2898
        if ($definedNameValue[0] !== '=') {
138✔
2899
            $definedNameValue = '=' . $definedNameValue;
115✔
2900
        }
2901

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

2904
        $originalCoordinate = $cell->getCoordinate();
138✔
2905
        $recursiveCalculationCell = ($definedNameType !== 'Formula' && $definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
138✔
2906
            ? $definedNameWorksheet->getCell('A1')
20✔
2907
            : $cell;
127✔
2908
        $recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();
138✔
2909

2910
        // Adjust relative references in ranges and formulae so that we execute the calculation for the correct rows and columns
2911
        $definedNameValue = ReferenceHelper::getInstance()
138✔
2912
            ->updateFormulaReferencesAnyWorksheet(
138✔
2913
                $definedNameValue,
138✔
2914
                Coordinate::columnIndexFromString(
138✔
2915
                    $cell->getColumn()
138✔
2916
                ) - 1,
138✔
2917
                $cell->getRow() - 1
138✔
2918
            );
138✔
2919

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

2922
        $recursiveCalculator = new self($this->spreadsheet);
138✔
2923
        $recursiveCalculator->getDebugLog()->setWriteDebugLog($this->getDebugLog()->getWriteDebugLog());
138✔
2924
        $recursiveCalculator->getDebugLog()->setEchoDebugLog($this->getDebugLog()->getEchoDebugLog());
138✔
2925
        $result = $recursiveCalculator->_calculateFormulaValue($definedNameValue, $recursiveCalculationCellAddress, $recursiveCalculationCell, true);
138✔
2926
        $cellWorksheet->getCell($originalCoordinate);
138✔
2927

2928
        if ($this->getDebugLog()->getWriteDebugLog()) {
138✔
UNCOV
2929
            $this->debugLog->mergeDebugLog(array_slice($recursiveCalculator->getDebugLog()->getLog(), 3));
×
UNCOV
2930
            $this->debugLog->writeDebugLog('Evaluation Result for Named %s %s is %s', $definedNameType, $namedRange->getName(), $this->showTypeDetails($result));
×
2931
        }
2932

2933
        $y = $namedRange->getWorksheet()?->getTitle();
138✔
2934
        $x = $namedRange->getLocalOnly();
138✔
2935
        if ($x && $y !== null) {
138✔
2936
            $stack->push('Defined Name', $result, "'$y'!" . $namedRange->getName());
20✔
2937
        } else {
2938
            $stack->push('Defined Name', $result, $namedRange->getName());
119✔
2939
        }
2940

2941
        return $result;
138✔
2942
    }
2943

2944
    public function setSuppressFormulaErrors(bool $suppressFormulaErrors): self
12✔
2945
    {
2946
        $this->suppressFormulaErrors = $suppressFormulaErrors;
12✔
2947

2948
        return $this;
12✔
2949
    }
2950

2951
    public function getSuppressFormulaErrors(): bool
14✔
2952
    {
2953
        return $this->suppressFormulaErrors;
14✔
2954
    }
2955

2956
    public static function boolToString(mixed $operand1): mixed
36✔
2957
    {
2958
        if (is_bool($operand1)) {
36✔
2959
            $operand1 = ($operand1) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
1✔
2960
        } elseif ($operand1 === null) {
36✔
2961
            $operand1 = '';
1✔
2962
        }
2963

2964
        return $operand1;
36✔
2965
    }
2966

2967
    private static function isNumericOrBool(mixed $operand): bool
42✔
2968
    {
2969
        return is_numeric($operand) || is_bool($operand);
42✔
2970
    }
2971

2972
    private static function makeError(mixed $operand = ''): string
3✔
2973
    {
2974
        return (is_string($operand) && Information\ErrorValue::isError($operand)) ? $operand : ExcelError::VALUE();
3✔
2975
    }
2976

2977
    private static function swapOperands(Stack $stack, string $opCharacter): bool
1,921✔
2978
    {
2979
        $retVal = false;
1,921✔
2980
        if ($stack->count() > 0) {
1,921✔
2981
            $o2 = $stack->last();
1,422✔
2982
            if ($o2) {
1,422✔
2983
                /** @var array{value: string} $o2 */
2984
                if (isset(self::CALCULATION_OPERATORS[$o2['value']])) {
1,422✔
2985
                    $retVal = (self::OPERATOR_PRECEDENCE[$opCharacter] ?? 0) <= self::OPERATOR_PRECEDENCE[$o2['value']];
129✔
2986
                }
2987
            }
2988
        }
2989

2990
        return $retVal;
1,921✔
2991
    }
2992

2993
    public function getSpreadsheet(): ?Spreadsheet
2✔
2994
    {
2995
        return $this->spreadsheet;
2✔
2996
    }
2997
}
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