Skip to content

Commit d2a2671

Browse files
feat(config): add arrayFunctionResultOverwritesData opt-in overwrite flag (HF-305)
Adds a public boolean Config option `arrayFunctionResultOverwritesData` (default `false`). When `false`, behavior is unchanged: an array spill onto an occupied cell yields `#SPILL!` and leaves the occupant intact. When `true`, the spill overwrites the occupied cells (clears occupants, spills the array, reroutes dependents) instead of emitting `#SPILL!`. Implementation reuses the existing spill-placement exchange primitive: - ConfigParams/Config: new option, mirrored on `useArrayArithmetic`. - DependencyGraph gains `config` + two guarded early-returns in exchangeOrAddFormulaVertex / setAddressMappingForArrayVertex. - Evaluator: recompute no longer emits `#SPILL!` when overwrite is allowed. Array-vs-array safety: a spill colliding with ANOTHER array always keeps `#SPILL!` and leaves that array intact, even in overwrite mode (canOverwriteArrayResult / overwriteWouldHitArray). This matches Excel and avoids corrupting a pre-existing array; overwrite only clears static data. Default `false` guarantees no existing embedder loses data silently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6e87a37 commit d2a2671

9 files changed

Lines changed: 421 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111

1212
- Added new functions: VSTACK, HSTACK. [#1698](https://github.com/handsontable/hyperformula/pull/1698)
1313
- Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701)
14+
- Added the `arrayFunctionResultOverwritesData` configuration option (default `false`). When enabled, an array function whose result spills onto occupied cells overwrites them instead of returning a `#SPILL!` error. This is an opt-in, destructive behavior; a collision with another array still yields `#SPILL!`.
1415
- Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674)
1516
- 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)
1617

src/Config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ export class Config implements ConfigParams, ParserConfig {
6969
useColumnIndex: false,
7070
useStats: false,
7171
useArrayArithmetic: false,
72+
arrayFunctionResultOverwritesData: false,
7273
}
7374

7475
/** @inheritDoc */
7576
public readonly useArrayArithmetic: boolean
7677
/** @inheritDoc */
78+
public readonly arrayFunctionResultOverwritesData: boolean
79+
/** @inheritDoc */
7780
public readonly caseSensitive: boolean
7881
/** @inheritDoc */
7982
public readonly chooseAddressMappingPolicy: ChooseAddressMapping
@@ -203,6 +206,7 @@ export class Config implements ConfigParams, ParserConfig {
203206
timeFormats,
204207
thousandSeparator,
205208
useArrayArithmetic,
209+
arrayFunctionResultOverwritesData,
206210
useStats,
207211
undoLimit,
208212
maxPendingLazyTransformations,
@@ -216,6 +220,7 @@ export class Config implements ConfigParams, ParserConfig {
216220
}
217221

218222
this.useArrayArithmetic = configValueFromParam(useArrayArithmetic, 'boolean', 'useArrayArithmetic')
223+
this.arrayFunctionResultOverwritesData = configValueFromParam(arrayFunctionResultOverwritesData, 'boolean', 'arrayFunctionResultOverwritesData')
219224
this.accentSensitive = configValueFromParam(accentSensitive, 'boolean', 'accentSensitive')
220225
this.caseSensitive = configValueFromParam(caseSensitive, 'boolean', 'caseSensitive')
221226
this.caseFirst = configValueFromParam(caseFirst, ['upper', 'lower', 'false'], 'caseFirst')

src/ConfigParams.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,25 @@ export interface ConfigParams {
390390
* @category Engine
391391
*/
392392
useArrayArithmetic: boolean,
393+
/**
394+
* When set to `true`, an array function whose result spills onto already-occupied cells
395+
* overwrites those cells (clearing their previous content, spilling the array, and rerouting
396+
* any dependents to the spilled values) instead of returning a `#SPILL!` error.
397+
*
398+
* **Warning:** this is a destructive, opt-in behavior. Enabling it clears whatever data
399+
* happens to sit in the spill range on the live sheet, so use it only when overwriting is the
400+
* intended outcome. The cleared cells are restored by `undo()`.
401+
*
402+
* The overwrite is applied when the array formula is evaluated (e.g. via `setCellContents`).
403+
* When set to `false`, an array spill onto an occupied cell yields `#SPILL!` and leaves the
404+
* occupant intact (the default, Excel-compatible behavior).
405+
*
406+
* Even when set to `true`, a spill that would collide with *another array* still yields
407+
* `#SPILL!` and leaves that array intact — overwrite mode never clobbers another array.
408+
* @default false
409+
* @category Engine
410+
*/
411+
arrayFunctionResultOverwritesData: boolean,
393412
/**
394413
* When set to `true`, switches column search strategy from binary search to column index.
395414
*

src/CrudOperations.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ export class CrudOperations {
262262
this.undoRedo.clearRedoStack()
263263

264264
const oldContents: { address: SimpleCellAddress, newContent: RawCellContent, oldContent: [SimpleCellAddress, ClipboardCell] }[] = []
265+
const overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []
265266

266267
for (let i = 0; i < cellContents.length; i++) {
267268
for (let j = 0; j < cellContents[i].length; j++) {
@@ -272,12 +273,13 @@ export class CrudOperations {
272273
}
273274
const newContent = cellContents[i][j]
274275
this.clipboardOperations.abortCut()
275-
const oldContent = this.operations.setCellContent(address, newContent)
276+
const {oldContent, overwrittenCells: overwritten} = this.operations.setCellContent(address, newContent)
276277
oldContents.push({address, newContent, oldContent})
278+
overwrittenCells.push(...overwritten)
277279
}
278280
}
279281

280-
this.undoRedo.saveOperation(new SetCellContentsUndoEntry(oldContents))
282+
this.undoRedo.saveOperation(new SetCellContentsUndoEntry(oldContents, overwrittenCells))
281283
}
282284

283285
public setSheetContent(sheetId: number, values: RawCellContent[][]): void {

src/DependencyGraph/DependencyGraph.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class DependencyGraph {
6363
public readonly lazilyTransformingAstService: LazilyTransformingAstService,
6464
public readonly functionRegistry: FunctionRegistry,
6565
public readonly namedExpressions: NamedExpressions,
66+
public readonly config: Config,
6667
) {
6768
this.graph = new Graph<Vertex>(this.dependencyQueryVertices)
6869
this.sheetReferenceRegistrar = new SheetReferenceRegistrar(sheetMapping, addressMapping)
@@ -82,7 +83,8 @@ export class DependencyGraph {
8283
stats,
8384
lazilyTransformingAstService,
8485
functionRegistry,
85-
namedExpressions
86+
namedExpressions,
87+
config
8688
)
8789
}
8890

@@ -480,6 +482,30 @@ export class DependencyGraph {
480482
return true
481483
}
482484

485+
/**
486+
* True when an array spill collision may be resolved by overwriting the occupants
487+
* (i.e. `arrayFunctionResultOverwritesData` is on) AND doing so would not clobber
488+
* another array. Array-vs-array collisions always keep `#SPILL!` (matches Excel and
489+
* avoids corrupting the pre-existing array), even in overwrite mode.
490+
*/
491+
public canOverwriteArrayResult(arrayVertex: ArrayFormulaVertex): boolean {
492+
return this.config.arrayFunctionResultOverwritesData && !this.overwriteWouldHitArray(arrayVertex)
493+
}
494+
495+
private overwriteWouldHitArray(arrayVertex: ArrayFormulaVertex): boolean {
496+
const range = arrayVertex.getRangeOrUndef()
497+
if (range === undefined) {
498+
return false
499+
}
500+
for (const address of range.addresses(this)) {
501+
const vertexUnderAddress = this.addressMapping.getCell(address)
502+
if (vertexUnderAddress instanceof ArrayFormulaVertex && vertexUnderAddress !== arrayVertex) {
503+
return true
504+
}
505+
}
506+
return false
507+
}
508+
483509
public moveCells(sourceRange: AbsoluteCellRange, toRight: number, toBottom: number, toSheet: number) {
484510
for (const sourceAddress of sourceRange.addressesWithDirection(toRight, toBottom, this)) {
485511
const targetAddress = simpleCellAddress(toSheet, sourceAddress.col + toRight, sourceAddress.row + toBottom)
@@ -1119,7 +1145,7 @@ export class DependencyGraph {
11191145
this.addressMapping.setCell(address, vertex)
11201146

11211147
if (vertex instanceof ArrayFormulaVertex) {
1122-
if (!this.isThereSpaceForArray(vertex)) {
1148+
if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(vertex)) {
11231149
return
11241150
}
11251151
for (const cellAddress of range.addresses(this)) {
@@ -1149,7 +1175,7 @@ export class DependencyGraph {
11491175
}
11501176
this.setArray(range, vertex)
11511177

1152-
if (!this.isThereSpaceForArray(vertex)) {
1178+
if (!this.isThereSpaceForArray(vertex) && !this.canOverwriteArrayResult(vertex)) {
11531179
return
11541180
}
11551181

src/Evaluator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export class Evaluator {
133133

134134
private recomputeFormulaVertexValue(vertex: FormulaVertex): InterpreterValue {
135135
const address = vertex.getAddress(this.lazilyTransformingAstService)
136-
if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || !this.dependencyGraph.isThereSpaceForArray(vertex))) {
136+
if (vertex instanceof ArrayFormulaVertex && (vertex.array.size.isRef || (!this.dependencyGraph.isThereSpaceForArray(vertex) && !this.dependencyGraph.canOverwriteArrayResult(vertex)))) {
137137
return vertex.setNoSpace()
138138
} else {
139139
const formula = vertex.getFormula(this.lazilyTransformingAstService)

src/Operations.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@ export interface MoveCellsResult {
154154
addedGlobalNamedExpressions: string[],
155155
}
156156

157+
export interface SetCellContentResult {
158+
/** Previous content of the anchor cell (the cell the new content is written to). */
159+
oldContent: [SimpleCellAddress, ClipboardCell],
160+
/**
161+
* Content of the non-anchor cells that an array formula overwrote while spilling
162+
* (only populated when `arrayFunctionResultOverwritesData` is on and the spill actually
163+
* overwrites static occupants). Empty otherwise. Used to make the overwrite undoable.
164+
*/
165+
overwrittenCells: [SimpleCellAddress, ClipboardCell][],
166+
}
167+
157168
export class Operations {
158169
private changes: ContentChanges = ContentChanges.empty()
159170
private readonly maxRows: number
@@ -595,9 +606,10 @@ export class Operations {
595606
return result
596607
}
597608

598-
public setCellContent(address: SimpleCellAddress, newCellContent: RawCellContent): [SimpleCellAddress, ClipboardCell] {
609+
public setCellContent(address: SimpleCellAddress, newCellContent: RawCellContent): SetCellContentResult {
599610
const parsedCellContent = this.cellContentParser.parse(newCellContent)
600611
const oldContent = this.getOldContent(address)
612+
let overwrittenCells: [SimpleCellAddress, ClipboardCell][] = []
601613

602614
if (parsedCellContent instanceof CellContent.Formula) {
603615
const parserResult = this.parser.parse(parsedCellContent.formula, address)
@@ -612,6 +624,7 @@ export class Operations {
612624
throw Error('Incorrect array size')
613625
}
614626

627+
overwrittenCells = this.snapshotOverwrittenOccupants(address, size)
615628
this.setFormulaToCell(address, size, parserResult)
616629
} catch (error) {
617630
if (!(error as Error).message) {
@@ -628,7 +641,55 @@ export class Operations {
628641
this.setValueToCell({ parsedValue: parsedCellContent.value, rawValue: newCellContent }, address)
629642
}
630643

631-
return oldContent
644+
return { oldContent, overwrittenCells }
645+
}
646+
647+
/**
648+
* Snapshots the cells that an array formula is about to overwrite while spilling, so the
649+
* overwrite can be undone. Returns an empty list unless `arrayFunctionResultOverwritesData`
650+
* is on and the array is non-scalar. Mirrors `DependencyGraph.canOverwriteArrayResult`:
651+
* if any cell in the spill range is itself an array, the spill will be blocked (`#SPILL!`)
652+
* and nothing is overwritten, so nothing is captured. The anchor cell is excluded because
653+
* its previous content is already captured separately as `oldContent`.
654+
*/
655+
private snapshotOverwrittenOccupants(anchorAddress: SimpleCellAddress, size: ArraySize): [SimpleCellAddress, ClipboardCell][] {
656+
return this.overwrittenOccupantAddresses(anchorAddress, size)
657+
.map(occupantAddress => [occupantAddress, this.getClipboardCell(occupantAddress)] as [SimpleCellAddress, ClipboardCell])
658+
}
659+
660+
/**
661+
* The occupied, non-array cells (excluding the anchor) that an array formula will overwrite while
662+
* spilling. Empty unless `arrayFunctionResultOverwritesData` is on and the array is non-scalar.
663+
* Mirrors `DependencyGraph.canOverwriteArrayResult`: if any cell in the spill range is itself an
664+
* array, the spill is blocked (`#SPILL!`) and nothing is overwritten, so the list is empty.
665+
*/
666+
private overwrittenOccupantAddresses(anchorAddress: SimpleCellAddress, size: ArraySize): SimpleCellAddress[] {
667+
if (!this.dependencyGraph.config.arrayFunctionResultOverwritesData || size.width * size.height <= 1) {
668+
return []
669+
}
670+
671+
const spillRange = AbsoluteCellRange.spanFromOrUndef(anchorAddress, size.width, size.height)
672+
if (spillRange === undefined) {
673+
return []
674+
}
675+
676+
const occupants: SimpleCellAddress[] = []
677+
for (const occupantAddress of spillRange.addresses(this.dependencyGraph)) {
678+
const vertex = this.dependencyGraph.getCell(occupantAddress)
679+
680+
if (vertex instanceof ArrayFormulaVertex) {
681+
// Array-vs-array collision: the spill stays #SPILL! and overwrites nothing.
682+
return []
683+
}
684+
685+
if (equalSimpleCellAddress(occupantAddress, anchorAddress) || vertex === undefined || vertex instanceof EmptyCellVertex) {
686+
continue
687+
}
688+
689+
occupants.push(occupantAddress)
690+
}
691+
692+
return occupants
632693
}
633694

634695
public setSheetContent(sheetId: number, newSheetContent: RawCellContent[][]) {
@@ -667,6 +728,12 @@ export class Operations {
667728
dependencies
668729
}: ParsingResult) {
669730
this.removeCellValueFromColumnSearch(address)
731+
// In overwrite mode the spill clears occupied cells; drop their stale values from the column
732+
// index too (their values are still live here, before the spill overwrites them), otherwise
733+
// VLOOKUP/MATCH with useColumnIndex could still match an overwritten value.
734+
for (const occupantAddress of this.overwrittenOccupantAddresses(address, size)) {
735+
this.removeCellValueFromColumnSearch(occupantAddress)
736+
}
670737

671738
const arrayChanges = this.dependencyGraph.setFormulaToCell(address, ast, absolutizeDependencies(dependencies, address), size, hasVolatileFunction, hasStructuralChangeFunction)
672739

src/UndoRedo.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export class SetCellContentsUndoEntry extends BaseUndoEntry {
350350
newContent: RawCellContent,
351351
oldContent: [SimpleCellAddress, ClipboardCell],
352352
}[],
353+
public readonly overwrittenCells: [SimpleCellAddress, ClipboardCell][] = [],
353354
) {
354355
super()
355356
}
@@ -640,6 +641,12 @@ export class UndoRedo {
640641
}
641642
this.operations.restoreCell(oldContentAddress, oldContent)
642643
}
644+
// Restore any cells that an array formula overwrote while spilling. This must run after
645+
// the anchor formulas above are undone, so the spill (and its array-internal cells) is
646+
// gone and the overwritten addresses are free to restore.
647+
for (const [address, clipboardCell] of operation.overwrittenCells) {
648+
this.operations.restoreCell(address, clipboardCell)
649+
}
643650
}
644651

645652
public undoPaste(operation: PasteUndoEntry) {

0 commit comments

Comments
 (0)