Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Changed

- Changed the cell errors produced while evaluating a formula to carry a message describing their cause. [#1547](https://github.com/handsontable/hyperformula/issues/1547)
- Changed the cell errors the engine produces, including invalidated references, circular references and error values typed into a formula, to carry a message describing their cause. [#1547](https://github.com/handsontable/hyperformula/issues/1547)

### Fixed

- Fixed the `EDATE` function returning the 28th of February instead of the 29th when the shifted date falls in February of a leap year (e.g. `=EDATE(DATE(2028, 1, 31), 1)` now returns `2028-02-29` instead of `2028-02-28`). [#1781](https://github.com/handsontable/hyperformula/pull/1781)
- Fixed a cell holding a static error value, such as a rejected formula or a typed-in error, being reported under the address of the cell that read it first instead of its own address. [#1547](https://github.com/handsontable/hyperformula/issues/1547)

## [3.5.0] - 2026-10-09

Expand Down
6 changes: 4 additions & 2 deletions hyperformula/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,14 @@ module.exports = {
}
},
{
files: ['**/src/interpreter/**/*.ts'],
files: ['**/src/**/*.ts'],
rules: {
// An engine-thrown cell error must always state its cause.
// The public `CellError` constructor keeps `message` optional for custom
// functions (docs/guide/custom-functions.md); this rule enforces the
// stricter contract on our own code only.
// stricter contract on our own code only. It is a syntactic floor, not a
// proof of completeness — see the comment at CellContentParser.ts's
// `CellContent.Error` constructor for a call site it cannot catch.
'no-restricted-syntax': ['error',
{
selector: "NewExpression[callee.name='CellError'][arguments.length<2]",
Expand Down
74 changes: 72 additions & 2 deletions hyperformula/src/Cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,80 @@ export class CellError {
constructor(
public readonly type: ErrorType,
public readonly message?: string,
public readonly root?: FormulaVertex
public readonly root?: FormulaVertex,
public readonly originFunction?: string,
public readonly argumentIndex?: number,
public readonly propagated: boolean = false,
public readonly originAddress?: SimpleCellAddress,
public readonly originAddressVersion?: number,
) {
}

/**
* Names the function or operator that produced this error.
*
* First occurrence wins, so an outer function that merely received the error back does not
* claim it: `=SUM(SQRT(-1))` keeps `SQRT`. A propagated error — one read out of another cell
* rather than produced here — can never acquire an origin it did not already have.
*
* @param {string} functionName - the function or operator helper that produced the error
*/
public withOrigin(functionName: string): CellError {
if (this.propagated || this.originFunction !== undefined) {
return this
}
return new CellError(this.type, this.message, this.root, functionName, this.argumentIndex, this.propagated, this.originAddress, this.originAddressVersion)
}

/**
* Records which argument of the origin function was rejected. First occurrence wins, and a
* propagated error never acquires one: the reading function's own argument slot is not the
* offending argument.
*
* @param {number} index - zero-based index of the offending argument
*/
public withArgumentIndex(index: number): CellError {
if (this.propagated || this.argumentIndex !== undefined) {
return this
}
return new CellError(this.type, this.message, this.root, this.originFunction, index, this.propagated, this.originAddress, this.originAddressVersion)
}

/**
* Marks this error as read from another cell rather than produced by the current evaluation.
*
* This is what stops a cell from adopting an error it only read: {@link attachRootVertex} skips
* a propagated error, so the reported address stays the one where the error actually appeared.
*/
public asPropagated(): CellError {
if (this.propagated) {
return this
}
return new CellError(this.type, this.message, this.root, this.originFunction, this.argumentIndex, true, this.originAddress, this.originAddressVersion)
}

/**
* Records the address an otherwise rootless error appeared at, and the moment it was recorded.
*
* For vertices that hold a static error rather than computing one (a parse error, or an error
* value the user typed), there is no `FormulaVertex` to serve as a lazily-resolved `root`, so
* the address has to be recorded on the value itself. A recorded address is a snapshot and does
* go stale: the cell that read it caches this instance, and a later row or column change that
* merely shifts that cell does not recompute it. The `version` is what makes the snapshot
* repairable — it names the point in `LazilyTransformingAstService`'s history the address was
* true at, so `Exporter` can replay the intervening transformations over it and report where the
* origin sits now. See {@link LazilyTransformingAstService.applyTransformationsToAddress}.
*
* @param {SimpleCellAddress} address - the address the error was read from
* @param {number} version - the transformation version that address was current at
*/
public withOriginAddress(address: SimpleCellAddress, version: number): CellError {
if (this.originAddress !== undefined) {
return this
}
return new CellError(this.type, this.message, this.root, this.originFunction, this.argumentIndex, this.propagated, address, version)
}

/**
* Returns a CellError with a given message.
* @param {string} detailedMessage - message to be displayed
Expand All @@ -164,7 +234,7 @@ export class CellError {

public attachRootVertex(vertex: FormulaVertex): CellError {
if (this.root === undefined) {
return new CellError(this.type, this.message, vertex)
return new CellError(this.type, this.message, vertex, this.originFunction, this.argumentIndex, this.propagated, this.originAddress, this.originAddressVersion)
} else {
return this
}
Expand Down
9 changes: 9 additions & 0 deletions hyperformula/src/CellContentParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ export namespace CellContent {
export class Error {
public readonly value: CellError

// `message` stays optional here on purpose. This constructor's only
// no-message caller is the `errorMapping` lookup below (a user typing e.g.
// `#REF!` literally into a cell) — the engine has no cause to state beyond
// "the user wrote this", so leaving it unset is honest, not an oversight.
// This is also the concrete case the cell-error ESLint rule (.eslintrc.js) cannot
// catch: the call `new CellError(errorType, message)` is syntactically a
// two-argument construction and passes the rule cleanly even when the
// caller passes no message, because the rule only inspects this call site,
// not who calls it.
constructor(errorType: ErrorType, message?: string) {
this.value = new CellError(errorType, message)
}
Expand Down
31 changes: 24 additions & 7 deletions hyperformula/src/DependencyGraph/DependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,26 @@ export class DependencyGraph {
return new CellError(ErrorType.REF, ErrorMessage.SheetRef)
}

return this.addressMapping.getCellValue(address)
const value = this.addressMapping.getCellValue(address)
if (!(value instanceof CellError)) {
return value
}

// Every read of another cell's value funnels through here, which makes this the one place
// that can tell "this error was produced by the current evaluation" from "this error was
// read from somewhere else". Marking it propagated is what stops the reading cell from
// being reported as the origin.
//
// A vertex holding a STATIC error (a parse error, or an error value the user typed) has no
// FormulaVertex to act as a root, so the address is stamped here instead, together with the
// transformation version it is true at. The reading cell caches the stamped copy and is not
// recomputed by a row or column change that merely shifts it, so the address alone would go
// stale; the version lets Exporter replay the intervening transformations over it.
const vertex = this.addressMapping.getCell(address)
const holdsAStaticError = vertex instanceof ParsingErrorVertex || vertex instanceof ValueCellVertex
return holdsAStaticError
? value.withOriginAddress(address, this.lazilyTransformingAstService.version()).asPropagated()
: value.asPropagated()
Comment thread
cursor[bot] marked this conversation as resolved.
}

public getRawValue(address: SimpleCellAddress): RawCellContent {
Expand All @@ -624,11 +643,9 @@ export class DependencyGraph {
}

public getScalarValue(address: SimpleCellAddress): InternalScalarValue {
if (this.isPlaceholder(address.sheet)) {
return new CellError(ErrorType.REF, ErrorMessage.SheetRef)
}

const value = this.addressMapping.getCellValue(address)
// Delegates rather than reading the address mapping itself, so that every caller of this
// method inherits the propagated marking above instead of bypassing it.
const value = this.getCellValue(address)
if (value instanceof SimpleRangeValue) {
return new CellError(ErrorType.VALUE, ErrorMessage.ScalarExpected)
}
Expand Down Expand Up @@ -966,7 +983,7 @@ export class DependencyGraph {
const oldValue = vertex.getArrayCellValue(address)
if (this.getCell(address) === vertex) {
if (vertex.isLeftCorner(address)) {
this.changes.addChange(new CellError(ErrorType.REF), address, oldValue)
this.changes.addChange(new CellError(ErrorType.REF, ErrorMessage.ArraySourceRemoved), address, oldValue)
} else {
this.addressMapping.removeCell(address)
this.changes.addChange(EmptyValue, address, oldValue)
Expand Down
2 changes: 1 addition & 1 deletion hyperformula/src/DependencyGraph/FormulaVertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export class ArrayFormulaVertex extends FormulaVertex {
try {
return this.array.get(col, row)
} catch (e) {
return new CellError(ErrorType.REF)
return new CellError(ErrorType.REF, ErrorMessage.OutsideArrayResult)
}
}

Expand Down
5 changes: 3 additions & 2 deletions hyperformula/src/Evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {Config} from './Config'
import {ContentChanges} from './ContentChanges'
import {ArrayFormulaVertex, DependencyGraph, RangeVertex, Vertex} from './DependencyGraph'
import {FormulaVertex} from './DependencyGraph/FormulaVertex'
import {ErrorMessage} from './error-message'
import {Interpreter} from './interpreter/Interpreter'
import {InterpreterState} from './interpreter/InterpreterState'
import {EmptyValue, getRawValue, InterpreterValue} from './interpreter/InterpreterValue'
Expand Down Expand Up @@ -105,7 +106,7 @@ export class Evaluator {
} else if (vertex instanceof FormulaVertex) {
const address = vertex.getAddress(this.lazilyTransformingAstService)
this.columnSearch.remove(getRawValue(vertex.valueOrUndef()), address)
const error = new CellError(ErrorType.CYCLE, undefined, vertex)
const error = new CellError(ErrorType.CYCLE, ErrorMessage.CircularReference, vertex)
vertex.setCellValue(error)
changes.addChange(error, address)
}
Expand All @@ -117,7 +118,7 @@ export class Evaluator {
private recomputeFormulas(cycled: Vertex[], sorted: Vertex[]): void {
cycled.forEach((vertex: Vertex) => {
if (vertex instanceof FormulaVertex) {
vertex.setCellValue(new CellError(ErrorType.CYCLE, undefined, vertex))
vertex.setCellValue(new CellError(ErrorType.CYCLE, ErrorMessage.CircularReference, vertex))
}
})
sorted.forEach((vertex: Vertex) => {
Expand Down
25 changes: 24 additions & 1 deletion hyperformula/src/Exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {ErrorMessage} from './error-message'
import {EmptyValue, getRawValue, InterpreterValue, isExtendedNumber} from './interpreter/InterpreterValue'
import {SimpleRangeValue} from './SimpleRangeValue'
import {LazilyTransformingAstService} from './LazilyTransformingAstService'
import {Maybe} from './Maybe'
import {NamedExpressions} from './NamedExpressions'
import {simpleCellAddressToString} from './parser/addressRepresentationConverters'
import { SheetMapping } from './DependencyGraph/SheetMapping'
Expand Down Expand Up @@ -115,7 +116,7 @@ export class Exporter implements ChangeExporter<ExportedChange> {

private detailedError(error: CellError): DetailedCellError {
let address = undefined
const originAddress = error.root?.getAddress(this.lazilyTransformingService)
const originAddress = error.root?.getAddress(this.lazilyTransformingService) ?? this.currentOriginAddress(error)
if (originAddress !== undefined) {
if (originAddress.sheet === NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS) {
address = this.namedExpressions.namedExpressionInAddress(originAddress.row)?.displayName
Expand All @@ -126,6 +127,28 @@ export class Exporter implements ChangeExporter<ExportedChange> {
return new DetailedCellError(error, this.config.translationPackage.getErrorTranslation(error.type), address)
}

/**
* Where a rootless error's origin sits now.
*
* An error produced by a formula resolves its address through its root vertex, which keeps
* itself current. An error read out of a cell that merely holds it — a parse error, or a value
* the user typed — has no such vertex, so it carries a snapshot address plus the transformation
* version that snapshot was true at, and the intervening transformations are replayed here.
* A named expression is exempt: its rows are handed out monotonically and never shifted.
*
* @param {CellError} error - the error being exported
*/
private currentOriginAddress(error: CellError): Maybe<SimpleCellAddress> {
const {originAddress, originAddressVersion} = error
if (originAddress === undefined || originAddressVersion === undefined) {
return originAddress
}
if (originAddress.sheet === NamedExpressions.SHEET_FOR_WORKBOOK_EXPRESSIONS) {
return originAddress
}
return this.lazilyTransformingService.applyTransformationsToAddress(originAddress, originAddressVersion)
}

private cellValueRounding(value: number): number {
if (value === 0) {
return value
Expand Down
42 changes: 41 additions & 1 deletion hyperformula/src/LazilyTransformingAstService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
import {SimpleCellAddress} from './Cell'
import {CombinedTransformer} from './dependencyTransformers/CombinedTransformer'
import {FormulaTransformer} from './dependencyTransformers/Transformer'
import {Ast, ParserWithCaching} from './parser'
import {Maybe} from './Maybe'
import {Ast, AstNodeType, ParserWithCaching} from './parser'
import {StatType} from './statistics'
import {Statistics} from './statistics/Statistics'
import {UndoRedo} from './UndoRedo'

/**
* Stand-in node for an address-only transformation. Every transformer leaves an EMPTY node
* untouched, so only the address it returns alongside it carries information.
*/
const ADDRESS_ONLY_NODE: Ast = {type: AstNodeType.EMPTY}

/**
* Manages lazy application of formula AST transformations.
*
Expand Down Expand Up @@ -106,6 +113,39 @@ export class LazilyTransformingAstService {
return [cachedAst, address, currentVersion]
}

/**
* Replays stored transformations over a bare address that carries no formula.
*
* `applyTransformations` above does the same walk for a formula vertex, which owns an AST and so
* also records undo data as it goes. A value has no AST to record, and the vertex that owns the
* formula still writes its own undo entry when it catches up, so this walk deliberately records
* nothing. It reuses each transformation's own `transformSingleAst` with an empty node rather
* than reaching for `fixNodeAddress`, because two transformers do more than shift coordinates:
* `MoveCellsTransformer` only moves an address that lies inside the range being moved, and that
* decision lives in `transformSingleAst`, not in `fixNodeAddress`.
*
* Returns `undefined` when the stamp predates the last compaction. Compaction brings every
* vertex up to date before it discards transformations, but a `CellError` cached inside a
* vertex's value is not a vertex and is never brought up to date, so its version can fall behind
* `versionOffset`. The transformations needed to move the address are gone by then, and naming
* some other cell is worse than naming none.
*
* @param {SimpleCellAddress} address - the address as it was at `version`
* @param {number} version - the version the address was current at
*/
public applyTransformationsToAddress(address: SimpleCellAddress, version: number): Maybe<SimpleCellAddress> {
if (version < this.versionOffset) {
return undefined
}

let transformed = address
const currentVersion = this.version()
for (let v = version; v < currentVersion; v++) {
transformed = this.transformations[v - this.versionOffset].transformSingleAst(ADDRESS_ONLY_NODE, transformed)[1]
}
return transformed
}

public* getTransformationsFrom(version: number, filter?: (transformation: FormulaTransformer) => boolean): IterableIterator<FormulaTransformer> {
const currentVersion = this.version()
for (let v = Math.max(version, this.versionOffset); v < currentVersion; v++) {
Expand Down
9 changes: 5 additions & 4 deletions hyperformula/src/dependencyTransformers/Transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {CellError, ErrorType, SimpleCellAddress} from '../Cell'
import {DependencyGraph} from '../DependencyGraph'
import {ErrorMessage} from '../error-message'
import {
Ast,
AstNodeType,
Expand Down Expand Up @@ -112,7 +113,7 @@ export abstract class Transformer implements FormulaTransformer {
if (newCellAddress instanceof CellAddress) {
return {...ast, reference: newCellAddress}
} else if (newCellAddress === ErrorType.REF) {
return buildCellErrorAst(new CellError(ErrorType.REF))
return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved))
} else {
return ast
}
Expand All @@ -123,7 +124,7 @@ export abstract class Transformer implements FormulaTransformer {
if (Array.isArray(newRange)) {
return {...ast, start: newRange[0], end: newRange[1]}
} else if (newRange === ErrorType.REF) {
return buildCellErrorAst(new CellError(ErrorType.REF))
return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved))
} else {
return ast
}
Expand All @@ -134,7 +135,7 @@ export abstract class Transformer implements FormulaTransformer {
if (Array.isArray(newRange)) {
return {...ast, start: newRange[0], end: newRange[1]}
} else if (newRange === ErrorType.REF) {
return buildCellErrorAst(new CellError(ErrorType.REF))
return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved))
} else {
return ast
}
Expand All @@ -145,7 +146,7 @@ export abstract class Transformer implements FormulaTransformer {
if (Array.isArray(newRange)) {
return {...ast, start: newRange[0], end: newRange[1]}
} else if (newRange === ErrorType.REF) {
return buildCellErrorAst(new CellError(ErrorType.REF))
return buildCellErrorAst(new CellError(ErrorType.REF, ErrorMessage.ReferenceRemoved))
} else {
return ast
}
Expand Down
Loading
Loading