Skip to content

Fix: ADDRESS function ignores defaultValue when user provides empty value for an optional parameter#1631

Merged
marcin-kordas-hoc merged 4 commits intodevelopfrom
fix/empty-default-value
Mar 24, 2026
Merged

Fix: ADDRESS function ignores defaultValue when user provides empty value for an optional parameter#1631
marcin-kordas-hoc merged 4 commits intodevelopfrom
fix/empty-default-value

Conversation

@marcin-kordas-hoc
Copy link
Copy Markdown
Collaborator

@marcin-kordas-hoc marcin-kordas-hoc commented Mar 5, 2026

Problem

When a user writes =ADDRESS(1,1,) or =ADDRESS(1,1,1,), the empty argument
is coerced to 0/false instead of using the parameter's declared defaultValue.

Excel 2021 and Google Sheets treat empty args as the zero-value for the type
(0/FALSE) for all functions except ADDRESS, where empty absNum and
a1Style use their declared defaults (1 and true).

Fixes #1632

Fix

  • Add emptyAsDefault opt-in flag to FunctionArgument interface
  • In coerceArgumentsToRequiredTypes: when rawArg === EmptyValue AND
    emptyAsDefault is set AND defaultValue is declared → substitute defaultValue
  • Apply emptyAsDefault: true only to ADDRESS absNum and a1Style parameters

Tests

Regression tests in handsontable/hyperformula-tests (branch fix/empty-default-value):

  • ADDRESS: isolated tests for empty absNum, empty a1Style, both empty
  • LOG, MATCH, VLOOKUP, HLOOKUP: confirm empty → zero-value (not defaultValue)
  • optional-parameters.spec.ts: confirms empty args use zero-value coercion (not defaultValue) when emptyAsDefault is not set

Note

Medium Risk
Touches core FunctionPlugin argument evaluation/coercion to distinguish syntactically empty arguments, which could subtly affect coercion behavior across many functions if misapplied. Change is gated behind an opt-in emptyAsDefault flag and only enabled for ADDRESS parameters in this PR.

Overview
Fixes ADDRESS so syntactically empty optional arguments (e.g. =ADDRESS(2,3,,FALSE())) use the parameter defaultValue instead of being coerced to the type’s zero-value.

Adds an opt-in emptyAsDefault flag to FunctionArgument and extends FunctionPlugin’s argument evaluation pipeline to track whether each argument was syntactically empty, allowing coercion to substitute defaultValue when emptyAsDefault is enabled. Documentation and changelog are updated to reflect the new option and the ADDRESS behavior fix.

Written by Cursor Bugbot for commit 7c6fc7c. This will update automatically on new commits. Configure here.

@marcin-kordas-hoc marcin-kordas-hoc requested a review from sequba March 5, 2026 12:56
@marcin-kordas-hoc marcin-kordas-hoc self-assigned this Mar 5, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 5, 2026

Performance comparison of head (7c6fc7c) vs base (4042b04)

                                     testName |   base |   head | change
------------------------------------------------------------------------
                                      Sheet A | 508.57 | 504.39 | -0.82%
                                      Sheet B | 158.45 | 158.61 | +0.10%
                                      Sheet T | 138.23 | 139.71 | +1.07%
                                Column ranges | 465.65 | 467.58 | +0.41%
Sheet A:  change value, add/remove row/column |  15.61 |  15.45 | -1.02%
 Sheet B: change value, add/remove row/column | 128.26 |  129.7 | +1.12%
                   Column ranges - add column | 143.71 | 145.31 | +1.11%
                Column ranges - without batch | 432.61 | 441.35 | +2.02%
                        Column ranges - batch | 108.87 | 112.67 | +3.49%

@marcin-kordas-hoc marcin-kordas-hoc changed the title fix: respect defaultValue when arg is AstNodeType.EMPTY fix(#1632): narrow empty arg defaultValue to ADDRESS only Mar 10, 2026
marcin-kordas-hoc pushed a commit that referenced this pull request Mar 18, 2026
Use the emptyAsDefault parameter flag from PR #1631 instead of manually
checking AstNodeType.EMPTY in the SEQUENCE method body. This removes the
coupling between the function logic and AST inspection, letting the
framework handle empty→default coercion declaratively.

Also restores test/testUtils.ts which was removed during merge.

https://claude.ai/code/session_01AXRRWx1KiQCiP8mriuxY7k
marcin-kordas-hoc and others added 2 commits March 19, 2026 13:09
…ault flag

When a user passes an empty argument to ADDRESS (e.g., =ADDRESS(2,3,,FALSE())),
the absNum and a1Style parameters now correctly use their declared defaultValue
instead of being coerced to 0/FALSE.

Adds an opt-in `emptyAsDefault` flag to FunctionArgument metadata. When set,
EmptyValue is treated as if the argument was omitted. Applied only to ADDRESS's
absNum and a1Style parameters — all other functions continue to coerce empty
args to the zero-value for the type, matching Excel behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sequba sequba changed the title fix(#1632): narrow empty arg defaultValue to ADDRESS only Fix: ADDRESS function ignores defaultValue when user provides empty value for an optional parameter Mar 20, 2026
Copy link
Copy Markdown
Contributor

@sequba sequba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job, solid fix.

Please:

  • add a CHANGELOG.md entry describing the fix
  • update the documentation (custom-functions guide) to describe the new emptyAsDefault configuration param

- Rewrite JSDoc to avoid internal EmptyValue term, add comparison table
- Add emptyAsDefault entry to argument validation options table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@marcin-kordas-hoc marcin-kordas-hoc force-pushed the fix/empty-default-value branch from f09a219 to c456e93 Compare March 23, 2026 10:11
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Empty cell references incorrectly treated as omitted arguments
    • Replaced the EmptyValue runtime check with a syntacticallyEmptyFlags array derived from AstNodeType.EMPTY, so only truly omitted arguments (not empty cell references) trigger the emptyAsDefault defaultValue substitution.

Create PR

Or push these changes by commenting:

@cursor push 4bd2d5663a
Preview (4bd2d5663a)
diff --git a/src/interpreter/plugin/FunctionPlugin.ts b/src/interpreter/plugin/FunctionPlugin.ts
--- a/src/interpreter/plugin/FunctionPlugin.ts
+++ b/src/interpreter/plugin/FunctionPlugin.ts
@@ -312,6 +312,29 @@
     return ret
   }
 
+  /**
+   * Builds syntactically-empty flags aligned with the expanded evaluated arguments.
+   * When ranges are expanded, a single AST node may produce multiple evaluated values;
+   * only nodes with `AstNodeType.EMPTY` are considered syntactically empty.
+   */
+  private buildSyntacticallyEmptyFlagsForExpandedArgs(args: Ast[], evaluatedArguments: [InterpreterValue, boolean][]): boolean[] {
+    const flags: boolean[] = []
+    let evalIdx = 0
+    for (const ast of args) {
+      const isEmpty = ast.type === AstNodeType.EMPTY
+      if (evalIdx < evaluatedArguments.length && evaluatedArguments[evalIdx][1]) {
+        while (evalIdx < evaluatedArguments.length && evaluatedArguments[evalIdx][1]) {
+          flags.push(isEmpty)
+          evalIdx++
+        }
+      } else {
+        flags.push(isEmpty)
+        evalIdx++
+      }
+    }
+    return flags
+  }
+
   protected coerceScalarToNumberOrError = (arg: InternalScalarValue): ExtendedNumber | CellError => this.arithmeticHelper.coerceScalarToNumberOrError(arg)
 
   protected coerceToType(arg: InterpreterValue, coercedType: FunctionArgument, state: InterpreterState): Maybe<InterpreterValue | complex | RawNoErrorScalarValue> {
@@ -410,6 +433,9 @@
     // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
     const argumentValues: InterpreterValue[] = evaluatedArguments.map(([value, _]: [InterpreterValue, boolean]) => value as InterpreterValue)
     const argumentIgnorableFlags = evaluatedArguments.map(([_, ignorable]) => ignorable)
+    const syntacticallyEmptyFlags = metadata.expandRanges
+      ? this.buildSyntacticallyEmptyFlagsForExpandedArgs(args, evaluatedArguments)
+      : args.map((ast) => ast.type === AstNodeType.EMPTY)
     const argumentMetadata = this.buildMetadataForEachArgumentValue(argumentValues.length, metadata)
     const isVectorizationOn = state.arraysFlag && !metadata.vectorizationForbidden
 
@@ -421,13 +447,13 @@
 
     if (resultArrayHeight === 1 && resultArrayWidth === 1) {
       const vectorizedArguments = this.vectorizeAndBroadcastArgumentsIfNecessary(isVectorizationOn, argumentValues, argumentMetadata, 0, 0)
-      return this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, functionImplementation, metadata.returnNumberType)
+      return this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, syntacticallyEmptyFlags, functionImplementation, metadata.returnNumberType)
     }
 
     const resultArray: InternalScalarValue[][] = [ ...Array(resultArrayHeight).keys() ].map(row =>
       [ ...Array(resultArrayWidth).keys() ].map(col => {
         const vectorizedArguments = this.vectorizeAndBroadcastArgumentsIfNecessary(isVectorizationOn, argumentValues, argumentMetadata, row, col)
-        const result = this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, functionImplementation, metadata.returnNumberType)
+        const result = this.calculateSingleCellOfResultArray(state, vectorizedArguments, argumentMetadata, argumentIgnorableFlags, syntacticallyEmptyFlags, functionImplementation, metadata.returnNumberType)
 
         if (result instanceof SimpleRangeValue) {
           throw new Error('Function returning array cannot be vectorized.')
@@ -445,10 +471,11 @@
     vectorizedArguments: Maybe<InterpreterValue>[],
     argumentsMetadata: FunctionArgument[],
     argumentIgnorableFlags: boolean[],
+    syntacticallyEmptyFlags: boolean[],
     functionImplementation: (...arg: any) => InterpreterValue,
     returnNumberType: NumberType | undefined,
   ): RawInterpreterValue {
-    const coercedArguments = this.coerceArgumentsToRequiredTypes(state, vectorizedArguments, argumentsMetadata, argumentIgnorableFlags)
+    const coercedArguments = this.coerceArgumentsToRequiredTypes(state, vectorizedArguments, argumentsMetadata, argumentIgnorableFlags, syntacticallyEmptyFlags)
 
     if (coercedArguments instanceof CellError) {
       return coercedArguments
@@ -463,15 +490,17 @@
     vectorizedArguments: Maybe<InterpreterValue>[],
     argumentsMetadata: FunctionArgument[],
     argumentIgnorableFlags: boolean[],
+    syntacticallyEmptyFlags: boolean[] = [],
   ):  CellError | Maybe<InterpreterValue | complex | RawNoErrorScalarValue>[] {
     const coercedArguments: Maybe<InterpreterValue | complex | RawNoErrorScalarValue>[] = []
 
     for (let i = 0; i < argumentsMetadata.length; i++) {
       const argumentMetadata = argumentsMetadata[i]
       const rawArg = vectorizedArguments[i]
+      const isSyntacticallyEmpty = !!syntacticallyEmptyFlags[i]
       const argumentValue = rawArg === undefined
         ? argumentMetadata?.defaultValue
-        : (rawArg === EmptyValue && argumentMetadata?.emptyAsDefault && argumentMetadata?.defaultValue !== undefined)
+        : (isSyntacticallyEmpty && argumentMetadata?.emptyAsDefault && argumentMetadata?.defaultValue !== undefined)
           ? argumentMetadata.defaultValue
           : rawArg

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@marcin-kordas-hoc marcin-kordas-hoc merged commit 266089d into develop Mar 24, 2026
32 checks passed
@marcin-kordas-hoc marcin-kordas-hoc deleted the fix/empty-default-value branch March 24, 2026 09:03
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.18%. Comparing base (4042b04) to head (7c6fc7c).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           develop    #1631   +/-   ##
========================================
  Coverage    97.18%   97.18%           
========================================
  Files          172      172           
  Lines        14836    14845    +9     
  Branches      3258     3264    +6     
========================================
+ Hits         14418    14427    +9     
  Misses         418      418           
Files with missing lines Coverage Δ
src/interpreter/plugin/AddressPlugin.ts 100.00% <ø> (ø)
src/interpreter/plugin/FunctionPlugin.ts 99.06% <100.00%> (+0.04%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants