diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e1da14..8d3204cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) +### Fixed + +- Fixed approximate `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` incorrectly returning `#N/A` (or a wrong position) when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697) + ## [3.3.0] - 2026-05-20 ### Added diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index 0c5f0d73b..5c5943cde 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -5,6 +5,7 @@ import {DependencyGraph} from '../DependencyGraph' import { + EmptyValue, getRawValue, InternalScalarValue, RawInterpreterValue, @@ -61,6 +62,13 @@ export abstract class AdvancedFind { ) } + /** + * Linear search over an in-memory array for the value equal to `searchKey`, or — when `ifNoMatch` + * is `returnLowerBound`/`returnUpperBound` — the closest non-exceeding/non-preceding value. + * Genuinely empty cells (`EmptyValue`) are skipped, consistent with `findLastOccurrenceInOrderedRange` + * and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search. + * Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. + */ protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray .map(getRawValue) @@ -88,6 +96,13 @@ export abstract class AdvancedFind { return i } + // Skip empty cells in the approximate search, consistent with findLastOccurrenceInOrderedRange: + // Excel/Google Sheets ignore genuinely empty cells (but not empty strings) when looking for the + // lower/upper bound. EmptyValue would otherwise be ranked below every value by compare(). + if (value === EmptyValue) { + continue + } + if (compareFn(value, searchKey) > 0) { continue } diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index 2823011a4..e1ee14947 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -20,6 +20,12 @@ const NOT_FOUND = -1 * * If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1. * + * Empty cells (EmptyValue) are skipped: they are not treated as ordered values during the + * approximate search. This mirrors Excel/Google Sheets, where MATCH/VLOOKUP/HLOOKUP/XLOOKUP + * ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound. + * The returned offset is always relative to the original range, so empty cells keep their slots + * and the position of the matched non-empty cell is reported unchanged. + * * Note: this function does not normalize input strings. */ export function findLastOccurrenceInOrderedRange( @@ -39,8 +45,47 @@ export function findLastOccurrenceInOrderedRange( ? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right) : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) - const foundIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end) - const foundValue = getValueFromIndexFn(foundIndex) + // Exact-match mode (returnNotFound) reports a hit iff the searchKey is genuinely present in the + // range, independent of ordering artifacts and interspersed empty cells. Fast path: run the binary + // search directly over the original range (O(log n)). A range with no empty gaps is monotonic, so an + // exact hit is found immediately and returned. Only when the direct search misses do we fall through + // to the O(n) empty-skipping compaction below (shared with the bound modes), which recovers a match + // that an interspersed blank would otherwise hide from the binary search. This keeps the common, + // gap-free case O(log n) while making exact match gap-independent (HF-223): binary search over a + // blank-containing range is non-monotonic, so a direct search alone can miss a value that is present. + if (ifNoMatch === 'returnNotFound') { + const directIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end) + if (directIndex !== NOT_FOUND && getValueFromIndexFn(directIndex) === searchKey) { + return directIndex - start + } + // Direct search missed — fall through to the empty-skipping compaction to recover a hidden match. + } + + // Collect the original indices of the non-empty cells, preserving their order. Empty cells break + // the sort invariant binary search relies on (compare() ranks EmptyValue below every other value), + // so the bound search runs over the compacted, empty-free index list and the result is mapped back + // to the original index space afterwards. + // + // This pre-scan is O(n) over the range, which trades away the binary search's O(log n) guarantee. + // It is required for correctness in the bound modes: with empty cells interspersed the search + // predicate is no longer monotonic, so the binary search cannot run directly on the original range. + const nonEmptyIndices: number[] = [] + for (let index = start; index <= end; index++) { + if (getValueFromIndexFn(index) !== EmptyValue) { + nonEmptyIndices.push(index) + } + } + + // With no non-empty cells there is nothing to match against. Return early so the + // ifNoMatch branches below (which treat NOT_FOUND as "key past the edge of a non-empty + // list" and may return offset 0) are not reached for an all-empty range. + if (nonEmptyIndices.length === 0) { + return NOT_FOUND + } + + const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1) + const foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex] + const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) if (foundValue === searchKey) { return foundIndex - start @@ -61,8 +106,8 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'desc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + const nextIndex = nonEmptyIndices[foundCompactedIndex + 1] + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } if (ifNoMatch === 'returnUpperBound') { @@ -80,8 +125,8 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'asc' - const nextIndex = foundIndex+1 - return nextIndex <= end ? nextIndex - start : NOT_FOUND + const nextIndex = nonEmptyIndices[foundCompactedIndex + 1] + return nextIndex !== undefined ? nextIndex - start : NOT_FOUND } // ifNoMatch === 'returnNotFound' diff --git a/src/interpreter/plugin/LookupPlugin.ts b/src/interpreter/plugin/LookupPlugin.ts index 00f942ddf..27ef4482d 100644 --- a/src/interpreter/plugin/LookupPlugin.ts +++ b/src/interpreter/plugin/LookupPlugin.ts @@ -12,7 +12,7 @@ import { ProcedureAst } from '../../parser' import { StatType } from '../../statistics' import { zeroIfEmpty } from '../ArithmeticHelper' import { InterpreterState } from '../InterpreterState' -import { InternalScalarValue, InterpreterValue, RawNoErrorScalarValue } from '../InterpreterValue' +import { EmptyValue, InternalScalarValue, InterpreterValue, RawNoErrorScalarValue } from '../InterpreterValue' import { SimpleRangeValue } from '../../SimpleRangeValue' import { FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions } from './FunctionPlugin' import { ArraySize } from '../../ArraySize' @@ -237,7 +237,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (value instanceof SimpleRangeValue) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } - return value + return this.zeroIfEmptyResult(value) } private doHlookup(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, index: number, searchOptions: SearchOptions): InternalScalarValue { @@ -265,7 +265,15 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech if (value instanceof SimpleRangeValue) { return new CellError(ErrorType.VALUE, ErrorMessage.WrongType) } - return value + return this.zeroIfEmptyResult(value) + } + + /** + * Excel returns 0 (not blank) for a matched cell that is empty — a reference to an empty cell coerces + * to 0 in Excel. Mirror that on the lookup RETURN value so VLOOKUP/HLOOKUP/XLOOKUP match Excel. + */ + private zeroIfEmptyResult(value: InternalScalarValue): InternalScalarValue { + return value === EmptyValue ? 0 : value } private doXlookup(key: RawNoErrorScalarValue, lookupRange: SimpleRangeValue, returnRange: SimpleRangeValue, notFoundFlag: any, isWildcardMatchMode: boolean, searchOptions: SearchOptions): InterpreterValue { @@ -284,7 +292,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech } const returnValues: InternalScalarValue[][] = isVerticalSearch ? [returnRange.data[indexFound]] : returnRange.data.map((row) => [row[indexFound]]) - return SimpleRangeValue.onlyValues(returnValues) + const coerced = returnValues.map((row) => row.map((value) => this.zeroIfEmptyResult(value))) + return SimpleRangeValue.onlyValues(coerced) } private doMatch(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, type: number): InternalScalarValue {