Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ 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)
- Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145)

### Fixed

- Fixed the behavior of `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697)
- Fixed the `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` functions to return `0` instead of an empty value when the matched cell in the result range is empty. [#1697](https://github.com/handsontable/hyperformula/pull/1697)

## [3.3.0] - 2026-05-20

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/guide/list-of-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google
| TIMEVALUE function | =TIMEVALUE("14:31") | Type of the returned value: `CellValueDetailedType.NUMBER_TIME` (compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard) | Cell auto-formatted as **regular number** | Cell auto-formatted as **regular number** |
| EDATE function | =EDATE(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** |
| EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** |
| Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)<br>where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. |

## Built-in functions

Expand Down
15 changes: 15 additions & 0 deletions src/Lookup/AdvancedFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {DependencyGraph} from '../DependencyGraph'
import {
EmptyValue,
getRawValue,
InternalScalarValue,
RawInterpreterValue,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
115 changes: 104 additions & 11 deletions src/interpreter/binarySearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,24 @@ const NOT_FOUND = -1
* - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range,
* - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound'
*
* If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1.
* If the search range contains duplicates, returns the last matching value, with one caveat: in the
* 'returnNotFound' mode, when the range contains both duplicates of the searchKey and interspersed
* empty cells, the search may report any of the duplicated positions (Excel's binary-search modes
* likewise leave this unspecified).
*
* 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 Google Sheets and Excel's linear approximate lookups, which
* ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound;
* for Excel's explicit binary search modes (XLOOKUP search_mode ±2) the result on a range with
* interspersed empty cells is unspecified — see docs/guide/list-of-differences.md.
* 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.
*
* Complexity: O(log n) as long as the binary-search descent probes no empty cell — in particular
* for ranges without empty cells. Only when the descent touches an empty cell does the search fall
* back to an O(n) scan that compacts the non-empty indices and re-runs the binary search over them.
*
* Note: this function does not normalize input strings.
*/
Expand All @@ -39,16 +56,82 @@ 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)
/*
* Returns the original index of the first non-empty cell at or after fromIndex, or undefined if
* all the remaining cells are empty. Costs O(gap length), which the search pays only when it
* actually needs to step over empty slots.
*/
const findNextNonEmptyIndex = (fromIndex: number): number | undefined => {
for (let index = fromIndex; index <= end; index++) {
if (getValueFromIndexFn(index) !== EmptyValue) {
return index
}
}
return undefined
}

// Fast path: binary search directly over the original range, tracking whether the descent ever
// probed an empty cell. Within the sorted-input contract, empty cells are the only source of
// non-monotonicity in the search predicate: compare() ranks EmptyValue below every non-empty
// value, and genuinely empty cells surface as the EmptyValue sentinel — empty strings and 0 do
// not. (Error values also break the ordering, but a range containing errors is outside the
// contract, and the compaction fallback keeps them too.) In a descent that never probes an empty
// cell every probed pivot is a correctly-ordered non-empty value, so each discarded half is
// justified by the monotonicity of the non-empty values, and the landing index — which is always
// probed — is the same one the compacted search below would return: the result can be trusted
// as-is. This keeps the search O(log n) unless an empty cell actually interferes.
let probedEmptyCell = false
const directIndex = findLastMatchingIndex(index => {
const value = getValueFromIndexFn(index)
if (value === EmptyValue) {
probedEmptyCell = true
}
return compareFn(searchKey, value) >= 0
}, start, end)

let foundIndex: number

if (!probedEmptyCell) {
foundIndex = directIndex
} else if (ifNoMatch === 'returnNotFound' && directIndex !== NOT_FOUND && getValueFromIndexFn(directIndex) === searchKey) {
// Exact-match mode: a misdirected descent cannot produce a false positive, because the landing
// value is re-checked for equality here. Accept the hit and skip the O(n) fallback.
return directIndex - start
} else {
// The descent probed an empty cell, so its result cannot be trusted (HF-223): collect the
// original indices of the non-empty cells (O(n)) and re-run the binary search over the
// compacted, empty-free index list. The result maps back to the original index space, so empty
// cells keep their slots and the matched non-empty cell's original position is reported
// unchanged. On an all-empty range the compacted list has no elements and the search reports
// NOT_FOUND, which the ifNoMatch branches below preserve.
const nonEmptyIndices: number[] = []
for (let index = start; index <= end; index++) {
if (getValueFromIndexFn(index) !== EmptyValue) {
nonEmptyIndices.push(index)
}
}

const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1)
foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex]
}

const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex)

if (foundValue === searchKey) {
return foundIndex - start
}

if (ifNoMatch === 'returnLowerBound') {
if (foundIndex === NOT_FOUND) {
return orderingDirection === 'asc' ? NOT_FOUND : 0
if (orderingDirection === 'asc') {
return NOT_FOUND
}

// orderingDirection === 'desc': the key exceeds every value in the range, so the lower bound
// is the first (largest) non-empty value — never an empty leading cell, and NOT_FOUND on an
// all-empty range.
const firstNonEmptyIndex = findNextNonEmptyIndex(start)
return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND
}

if (typeof foundValue !== typeof searchKey) {
Expand All @@ -60,14 +143,23 @@ export function findLastOccurrenceInOrderedRange(
return foundIndex - start
}

// orderingDirection === 'desc'
const nextIndex = foundIndex+1
return nextIndex <= end ? nextIndex - start : NOT_FOUND
// orderingDirection === 'desc': step to the next non-empty cell, so skipped empty slots never
// shift the reported position.
const nextIndex = findNextNonEmptyIndex(foundIndex + 1)
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
}

if (ifNoMatch === 'returnUpperBound') {
if (foundIndex === NOT_FOUND) {
return orderingDirection === 'asc' ? 0 : NOT_FOUND
if (orderingDirection === 'desc') {
return NOT_FOUND
}

// orderingDirection === 'asc': the key precedes every value in the range, so the upper bound
// is the first (smallest) non-empty value — never an empty leading cell, and NOT_FOUND on an
// all-empty range.
const firstNonEmptyIndex = findNextNonEmptyIndex(start)
return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND
}

if (typeof foundValue !== typeof searchKey) {
Expand All @@ -79,9 +171,10 @@ export function findLastOccurrenceInOrderedRange(
return foundIndex - start
}

// orderingDirection === 'asc'
const nextIndex = foundIndex+1
return nextIndex <= end ? nextIndex - start : NOT_FOUND
Comment thread
cursor[bot] marked this conversation as resolved.
// orderingDirection === 'asc': step to the next non-empty cell, so skipped empty slots never
// shift the reported position.
const nextIndex = findNextNonEmptyIndex(foundIndex + 1)
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
}

// ifNoMatch === 'returnNotFound'
Expand Down
20 changes: 16 additions & 4 deletions src/interpreter/plugin/LookupPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -265,7 +265,18 @@ 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.
*
* Same rule as `ArithmeticHelper.zeroIfEmpty`, kept as a separate method because the lookup RETURN
* value is an `InternalScalarValue` (wider than that helper's `RawNoErrorScalarValue` parameter).
*/
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 {
Expand All @@ -284,7 +295,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)
Comment thread
cursor[bot] marked this conversation as resolved.
}

private doMatch(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, type: number): InternalScalarValue {
Expand Down
Loading