getMessage(); } if (!in_array($matchMode, [0, -1, 1, 2], true)) { return ExcelError::VALUE(); } if (count($lookupArray) !== count($returnArray)) { return ExcelError::VALUE(); } try { switch ($searchMode) { case 1: $index = self::searchLinear($lookupValue, $lookupArray, $matchMode, false); break; case -1: $index = self::searchLinear($lookupValue, $lookupArray, $matchMode, true); break; case 2: $index = self::searchBinary($lookupValue, $lookupArray, $matchMode, true); break; case -2: $index = self::searchBinary($lookupValue, $lookupArray, $matchMode, false); break; } } catch (UnhandledMatchError $exception) { return ExcelError::VALUE(); } return ($index === null) ? $ifNotFound : $returnArray[$index]; } // --------------------------------------------------------------------------- // Search strategies // --------------------------------------------------------------------------- /** * Linear search (searchMode 1 and -1). * * @param mixed[] $lookupArray * @param mixed $lookupValue */ private static function searchLinear( $lookupValue, array $lookupArray, int $matchMode, bool $reverse ): ?int { $keys = array_keys($lookupArray); if ($reverse) { $keys = array_reverse($keys); } $bestIdx = null; $bestVal = null; foreach ($keys as $i) { /** @var scalar */ $candidate = $lookupArray[$i]; if ($matchMode === 2) { // Wildcard: convert Excel wildcards to PHP regex /** @var scalar $lookupValue */ if (self::wildcardMatch((string) $lookupValue, (string) $candidate)) { return $i; } continue; } $cmp = self::compareValues($candidate, $lookupValue); if ($cmp === 0) { return $i; // Exact match — return immediately } if ($matchMode === -1 && $cmp < 0) { // Next smaller: track largest value still below lookupValue if ($bestVal === null || self::compareValues($candidate, $bestVal) > 0) { $bestVal = $candidate; $bestIdx = $i; } } if ($matchMode === 1 && $cmp > 0) { // Next larger: track smallest value still above lookupValue if ($bestVal === null || self::compareValues($candidate, $bestVal) < 0) { $bestVal = $candidate; $bestIdx = $i; } } } /** @var ?int $bestIdx */ return $bestIdx; } /** * Binary search (searchMode 2 and -2) * Assumes array is sorted ascending (searchMode 2) or descending (searchMode -2). * * @param mixed[] $lookupArray * @param mixed $lookupValue */ private static function searchBinary( $lookupValue, array $lookupArray, int $matchMode, bool $ascending ): ?int { $values = array_values($lookupArray); $keys = array_keys($lookupArray); $lo = 0; $hi = count($values) - 1; $bestIdx = null; while ($lo <= $hi) { $mid = intdiv($lo + $hi, 2); $cmp = self::compareValues($values[$mid], $lookupValue); if (!$ascending) { $cmp = -$cmp; // Flip for descending } if ($cmp === 0) { return $keys[$mid]; // Exact match } if ($cmp < 0) { if ($matchMode === -1) { $bestIdx = $keys[$mid]; // Candidate for next smaller } $lo = $mid + 1; } else { if ($matchMode === 1) { $bestIdx = $keys[$mid]; // Candidate for next larger } $hi = $mid - 1; } } /** @var int $bestIdx */ return ($matchMode !== 0) ? $bestIdx : null; } /** * Compare two values with type coercion matching Excel's behaviour: * numbers < strings < booleans * @param mixed $a * @param mixed $b */ private static function compareValues($a, $b): int { // Numeric comparison if (is_numeric($a) && is_numeric($b)) { return $a <=> $b; } // String comparison (case-insensitive, like Excel) if (is_string($a) && is_string($b)) { return strcasecmp($a, $b); } // Bool comparison if (is_bool($a) && is_bool($b)) { return $a <=> $b; } // Cross-type: number < string < bool $typeOrder = function ($v) { switch (true) { case is_numeric($v): return 0; case is_string($v): return 1; case is_bool($v): return 2; default: return 3; } }; return $typeOrder($a) <=> $typeOrder($b); } /** * Wildcard match (matchMode 2) * Supports Excel wildcards: * (any sequence), ? (any single char), ~ (escape). */ private static function wildcardMatch(string $pattern, string $subject): bool { // Handle ~* and ~? escapes first $regex = ''; $len = strlen($pattern); for ($i = 0; $i < $len; ++$i) { $ch = $pattern[$i]; if ($ch === '~' && $i + 1 < $len) { $next = $pattern[++$i]; $regex .= preg_quote($next, '/'); } elseif ($ch === '*') { $regex .= '.*'; } elseif ($ch === '?') { $regex .= '.'; } else { $regex .= preg_quote($ch, '/'); } } return (bool) preg_match('/^' . $regex . '$/i', $subject); } }