Skip to content

fix: keep implicit multiplication round-trippable after a symbol or accessor#3661

Open
sarathfrancis90 wants to merge 1 commit into
josdejong:developfrom
sarathfrancis90:fix/implicit-multiply-constant-roundtrip
Open

fix: keep implicit multiplication round-trippable after a symbol or accessor#3661
sarathfrancis90 wants to merge 1 commit into
josdejong:developfrom
sarathfrancis90:fix/implicit-multiply-constant-roundtrip

Conversation

@sarathfrancis90

Copy link
Copy Markdown

Summary

Node.toString() of an implicit multiplication can produce a string that no longer parses back to the same expression when the left-hand factor ends with a symbol or an accessor. The serialized output reparses as a function call or an index access instead of a multiplication, so the round-trip parse(str).toString() is broken and re-evaluating the serialized form throws or returns a different value.

const node = math.parse('x 2')
node.toString()            // 'x (2)'   <-- was wrong
math.parse('x (2)')        // parses as the function call x(2)
math.parse(node.toString()).evaluate({ x: 5 })  // throws: "x is not a function"

Root cause

In src/expression/node/OperatorNode.js, when stringifying an implicit multiplication with implicit: 'hide', a ConstantNode that follows an unparenthesized factor is deliberately wrapped in parentheses. This exists so that an expression such as 4 4 (two adjacent constants, which is not valid input) serializes recoverably as 4 (4).

The problem is that this rule fired regardless of what the preceding factor was. When the preceding factor ends with a SymbolNode or an AccessorNode, a directly-following parenthesis binds as invocation/indexing:

  • x 2x (2) → parses as the call x(2)
  • a b 2a b (2) → parses as a * b(2)
  • A[1] 2A[1] (2)→ parses as the index access A[1](2)

A parenthesized expression / function call already ends with ), and )(...) is treated by the parser as implicit multiplication, so those cases were and remain safe — only a trailing symbol or accessor is affected.

Fix

Add a small helper endsWithSymbolOrAccessor(expr, parenthesis) that determines whether the printed form of the preceding factor ends with a symbol or accessor (recursing into the last-printed operand of operator nodes, and correctly distinguishing prefix unary operators like -a from postfix ones like a!). The forced-parentheses branch now skips wrapping the ConstantNode in that case, leaving the bare constant, which is unambiguous and recoverable:

  • x 2x 2 → parses back to x * 2
  • a b 2a b 2 → parses back to a * b * 2
  • A[1] 2A[1] 2 → parses back to A[1] * 2

The original recoverable cases are preserved ((4)(4)4 (4), sin(1)2sin(1) (2)).

Tests

Added a unit test in test/unit-tests/expression/node/OperatorNode.test.js that, for both parenthesis: 'auto' and parenthesis: 'keep', asserts the serialized form reparses to the identical grouping for a symbol factor, a nested symbol product, and an accessor factor, and spot-checks the exact strings (x 2, a b 2, A[1] 2). The test fails on the current code ('x (2)' !== 'x 2') and passes with the fix.

Verified locally:

  • npm run lint — clean (--max-warnings 0)
  • Full OperatorNode unit suite — 53 passing
  • Full source test suite, generated tests, node tests, type tests, and build — all green

Only files under src (plus the test and a HISTORY.md entry) are changed; no dist/lib builds are committed. PR is targeted at develop per the contributing guidelines.

…ccessor

When stringifying an implicit multiplication, a ConstantNode that follows an
unparenthesized factor was wrapped in parentheses so that adjacent numbers such
as `4 4` (which is not valid input) would be serialized recoverably as `4 (4)`.

However, when the preceding factor ends with a SymbolNode or AccessorNode the
parenthesized ConstantNode reparses as a function call or index access rather
than an implicit multiplication. For example `parse('x 2').toString()` returned
`'x (2)'`, which parses back as the function call `x(2)` instead of `x * 2`, so
the node no longer round-tripped and evaluating the serialized form threw or
produced a different value. The same applied to `a b 2` (-> `a b (2)`, parsed as
`a * b(2)`) and `A[1] 2` (-> `A[1] (2)`, parsed as the index access `A[1](2)`).

Only force the parentheses when the preceding factor does not end with a symbol
or accessor, in which case the bare ConstantNode is unambiguous and recoverable.
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.

1 participant