From 5361c44ab28f15f0aa14ed6b10a05f72d0ccdffc Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 14 Sep 2026 17:52:29 +0100 Subject: [PATCH 1/3] fix(HF-268): support consecutive percent operators Formulas such as =5%% failed to parse even though =(5%)% worked. Consume consecutive postfix percent operators and wrap the preceding expression for each token, so =5%% returns 0.0005 and =5%%% returns 0.000005, matching Excel. Preserve each operator's whitespace and the existing operator precedence. Document the behavior in the operator guide and Unreleased changelog. Validated with the matching fix/HF-268 test branch: 502 Jest suites and 6,254 tests passed, and Chrome and Firefox each passed 6,254 tests. Compilation and performance benchmarks passed. Full lint passed with zero errors and existing warnings using the test TypeScript project. --- CHANGELOG.md | 1 + docs/guide/types-of-operators.md | 6 +++++- src/parser/FormulaParser.ts | 18 +++++++++--------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..83ca6d0aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed parsing of consecutive percent operators, such as `=5%%`. - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/types-of-operators.md b/docs/guide/types-of-operators.md index 39f9ef9f55..403678e778 100644 --- a/docs/guide/types-of-operators.md +++ b/docs/guide/types-of-operators.md @@ -38,7 +38,11 @@ returns the negative value of that number. | :--- | :--- | :--- | :--- | | - | Unary minus | -a | Returns the negative of its argument. | | + | Unary plus | +a | Returns the positive of its argument. | -| % | Percent | a% | Calculate the percent of an argument. | +| % | Percent | a% | Divides its argument by 100. | + +HyperFormula supports consecutive percent operators. Each `%` divides the +preceding result by `100`. For example, `=5%%` is equivalent to `=(5%)%` +and returns `0.0005`, while `=5%%%` returns `0.000005`. ## Binary arithmetic operators diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index 82c70e1c1d..6241bff438 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -561,18 +561,18 @@ export class FormulaParser extends EmbeddedActionsParser { ])) as Ast }) + /** + * Rule for atomic expressions followed by zero or more percent operators. + */ private rightUnaryOpAtomicExpression: AstRule = this.RULE('rightUnaryOpAtomicExpression', () => { - const positiveAtomicExpression = this.SUBRULE(this.positiveAtomicExpression) - - const percentage = this.OPTION(() => { - return this.CONSUME(PercentOp) - }) as Maybe + let expression: Ast = this.SUBRULE(this.positiveAtomicExpression) - if (percentage) { - return buildPercentOpAst(positiveAtomicExpression, percentage.leadingWhitespace) - } + this.MANY(() => { + const percentage = this.CONSUME(PercentOp) as ExtendedToken + expression = buildPercentOpAst(expression, percentage.leadingWhitespace) + }) - return positiveAtomicExpression + return expression }) /** From 56a15f16c4a594839a52dfa6081435278b2bb235 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Mon, 14 Sep 2026 18:19:15 +0100 Subject: [PATCH 2/3] docs(HF-268): link changelog entry to PR #1773 Add the pull request reference to the consecutive percent operators changelog entry, matching the surrounding entries. Validated the PR target and checked the Markdown diff for whitespace errors. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ca6d0aa9..f6e152b489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- Fixed parsing of consecutive percent operators, such as `=5%%`. +- Fixed parsing of consecutive percent operators, such as `=5%%`. [#1773](https://github.com/handsontable/hyperformula/pull/1773) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) From f8720e6c50f513f4ad7e5a87157f79c2617d320d Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 30 Sep 2026 10:03:24 +0100 Subject: [PATCH 3/3] refactor(HF-268): collect percent operators before folding Gather the consecutive PercentOp tokens in the MANY loop and build the nested percent AST with a single reduce afterwards, keeping token consumption separate from AST construction. Behavior is unchanged: =5%% returns 0.0005 and each operator keeps its leading whitespace. Validated with the matching fix/HF-268 test branch: 502 Jest suites and 6,254 tests passed (3 skipped). Type-check and lint passed with zero errors. --- src/parser/FormulaParser.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parser/FormulaParser.ts b/src/parser/FormulaParser.ts index 6241bff438..21eb2a043e 100644 --- a/src/parser/FormulaParser.ts +++ b/src/parser/FormulaParser.ts @@ -565,14 +565,14 @@ export class FormulaParser extends EmbeddedActionsParser { * Rule for atomic expressions followed by zero or more percent operators. */ private rightUnaryOpAtomicExpression: AstRule = this.RULE('rightUnaryOpAtomicExpression', () => { - let expression: Ast = this.SUBRULE(this.positiveAtomicExpression) + const operand = this.SUBRULE(this.positiveAtomicExpression) + const percentOps: ExtendedToken[] = [] this.MANY(() => { - const percentage = this.CONSUME(PercentOp) as ExtendedToken - expression = buildPercentOpAst(expression, percentage.leadingWhitespace) + percentOps.push(this.CONSUME(PercentOp) as ExtendedToken) }) - return expression + return percentOps.reduce((ast, op) => buildPercentOpAst(ast, op.leadingWhitespace), operand) }) /**