|
64 | 64 | } |
65 | 65 |
|
66 | 66 |
|
| 67 | +# Operator precedence in Excel formulas. Higher binds tighter. A child BinOp |
| 68 | +# whose precedence is *strictly less* than the parent's must be wrapped in |
| 69 | +# parens; same-precedence children only need wrapping on the right side of |
| 70 | +# non-commutative operators (``a - (b - c)`` ≠ ``(a - b) - c``). |
| 71 | +_BINOP_PRECEDENCE: Dict[str, int] = { |
| 72 | + "**": 4, "^": 4, |
| 73 | + "*": 3, "/": 3, "//": 3, "%": 3, |
| 74 | + "+": 2, "-": 2, |
| 75 | + "&": 1, |
| 76 | + "==": 0, "!=": 0, "<": 0, "<=": 0, ">": 0, ">=": 0, |
| 77 | +} |
| 78 | + |
| 79 | +# Non-commutative-on-the-right ops: same-precedence right child needs parens. |
| 80 | +# `a - b - c` is fine (left-associative) but `a - (b - c)` must keep them. |
| 81 | +_NON_COMMUTATIVE_RIGHT: set[str] = {"-", "/", "//", "%", "**", "^"} |
| 82 | + |
| 83 | + |
67 | 84 | # Functions that accept an Excel range for any list-valued VarRef argument. |
68 | 85 | # The translator renders those args as ranges (``B2:F2``) and leaves |
69 | 86 | # scalar VarRefs / literals as normal. Covers classic aggregates (SUM, |
@@ -216,17 +233,70 @@ def render_varref(self, node: VarRef, ctx: RenderCtx) -> str: |
216 | 233 | return _value_to_excel_literal(_value_at_period(node.var._value, ctx.period_idx)) |
217 | 234 |
|
218 | 235 | def render_binop(self, node: BinOp, ctx: RenderCtx) -> str: |
219 | | - left = self.walker.render(node.left, ctx) |
220 | | - right = self.walker.render(node.right, ctx) |
221 | 236 | op = node.op |
222 | | - # // → INT(a/b), % → MOD(a,b) — Excel has no direct operator for these |
| 237 | + # // → INT(a/b), % → MOD(a,b). Inside INT/MOD the relevant parent op |
| 238 | + # is ``/`` (precedence 3); the comma in MOD separates and needs no |
| 239 | + # parens-handling on either operand. |
223 | 240 | if op == "//": |
| 241 | + left = self._render_operand(node.left, "/", ctx, side="left") |
| 242 | + right = self._render_operand(node.right, "/", ctx, side="right") |
224 | 243 | return f"INT({left}/{right})" |
225 | 244 | if op == "%": |
| 245 | + left = self.walker.render(node.left, ctx) |
| 246 | + right = self.walker.render(node.right, ctx) |
226 | 247 | return f"MOD({left},{right})" |
| 248 | + |
| 249 | + left = self._render_operand(node.left, op, ctx, side="left") |
| 250 | + right = self._render_operand(node.right, op, ctx, side="right") |
227 | 251 | excel_op = _PY_TO_EXCEL_OP.get(op, op) |
228 | 252 | return f"{left} {excel_op} {right}" |
229 | 253 |
|
| 254 | + def _render_operand( |
| 255 | + self, child: Expr, parent_op: str, ctx: RenderCtx, *, side: str |
| 256 | + ) -> str: |
| 257 | + """Render a BinOp operand and wrap in parens iff Excel precedence |
| 258 | + would otherwise re-associate the expression incorrectly. |
| 259 | +
|
| 260 | + Wrapping is decided by the *effective* top-level operator of the |
| 261 | + rendered string — `_effective_op` follows VarRefs into floating |
| 262 | + Variables (whose `_expr` is rendered inline) so we don't miss |
| 263 | + compound subtrees that present as VarRef in the AST. |
| 264 | + """ |
| 265 | + rendered = self.walker.render(child, ctx) |
| 266 | + effective = self._effective_op(child) |
| 267 | + if effective is None: |
| 268 | + return rendered |
| 269 | + parent_prec = _BINOP_PRECEDENCE.get(parent_op, 0) |
| 270 | + child_prec = _BINOP_PRECEDENCE.get(effective, 0) |
| 271 | + if child_prec < parent_prec: |
| 272 | + return f"({rendered})" |
| 273 | + if ( |
| 274 | + child_prec == parent_prec |
| 275 | + and side == "right" |
| 276 | + and parent_op in _NON_COMMUTATIVE_RIGHT |
| 277 | + ): |
| 278 | + return f"({rendered})" |
| 279 | + return rendered |
| 280 | + |
| 281 | + def _effective_op(self, node: Expr) -> str | None: |
| 282 | + """The operator that would govern this node's rendered string, or |
| 283 | + ``None`` if it renders as an atomic token (cell ref, literal, |
| 284 | + function call, already-parenthesized subexpr). |
| 285 | +
|
| 286 | + Follows VarRefs into floating Variables, since their `_expr` is |
| 287 | + rendered inline at the parent's call site. |
| 288 | + """ |
| 289 | + if isinstance(node, BinOp): |
| 290 | + return node.op |
| 291 | + if isinstance(node, VarRef): |
| 292 | + var = node.var |
| 293 | + if var.id in self.addresses: |
| 294 | + return None # rendered as a cell address — atomic |
| 295 | + if var._expr is not None: |
| 296 | + return self._effective_op(var._expr) |
| 297 | + return None |
| 298 | + return None |
| 299 | + |
230 | 300 | def render_subscript(self, node: Subscript, ctx: RenderCtx) -> str: |
231 | 301 | if not isinstance(node.base, VarRef): |
232 | 302 | logger.warning( |
|
0 commit comments