From 8be4988f493dffd189b796ca412f63ba8f61b563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Wed, 4 Nov 2026 04:09:36 -0300 Subject: [PATCH] fix: preserve arithmetic unary operator boundaries Printing `(( - -x ))` currently produces `(( --x ))`. With `x=7`, that changes the result to 6 and decrements x. The same issue turns `+ +x` into an increment. Keep a separator when a unary + or - would otherwise join the same leading sign on its operand. The regression tests cover the operator tree after reparsing, nested signs, increment/decrement operands and arithmetic for loops. Validation: 2,063 tests, build/types and lint passed on Node 24. The package smoke test passed on Node 14.21.3, and execution checks against Bash 3.2 and 5.1 preserve both values and mutations for the covered expressions. --- src/printer.ts | 14 +++++++-- test/printer.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/printer.ts b/src/printer.ts index 30f8a81..cd445b7 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -388,9 +388,17 @@ function arithExpr(e: ArithmeticExpression): string { return e.value; case "ArithmeticGroup": return "(" + arithExpr(e.expression) + ")"; - case "ArithmeticUnary": - if (e.prefix) return (e.operator === "!" ? "! " : e.operator) + arithExpr(e.operand); - return arithExpr(e.operand) + e.operator; + case "ArithmeticUnary": { + const operand = arithExpr(e.operand); + if (e.prefix) { + const separator = + e.operator === "!" || ((e.operator === "+" || e.operator === "-") && operand.startsWith(e.operator)) + ? " " + : ""; + return e.operator + separator + operand; + } + return operand + e.operator; + } case "ArithmeticTernary": return arithExpr(e.test) + " ? " + arithExpr(e.consequent) + " : " + arithExpr(e.alternate); case "ArithmeticBinary": { diff --git a/test/printer.test.ts b/test/printer.test.ts index b9f9b1b..fd41bb5 100644 --- a/test/printer.test.ts +++ b/test/printer.test.ts @@ -322,6 +322,62 @@ test("arithmetic unary operators retain their spelling", () => { assert.equal(fmt("(( ~ 1 + - 2 + + 3 + ++ i + j -- ))"), "(( ~1 + -2 + +3 + ++i + j-- ))"); }); +for (const operator of ["-", "+"]) { + test(`arithmetic nested unary ${operator} preserves both operators`, () => { + const printed = fmt(`(( ${operator} ${operator}x ))`); + const reparsed = parse(printed); + const command = nodeOfType(reparsed.commands[0].command, "ArithmeticCommand"); + const outer = nodeOfType(command.expression, "ArithmeticUnary"); + assert.equal(outer.operator, operator); + assert.equal(outer.prefix, true); + const inner = nodeOfType(outer.operand, "ArithmeticUnary"); + assert.equal(inner.operator, operator); + assert.equal(inner.prefix, true); + assert.equal(nodeOfType(inner.operand, "ArithmeticWord").value, "x"); + assert.equal(reparsed.errors, undefined); + }); +} + +test("arithmetic unary token boundaries survive printing and reparsing", () => { + const shape = (value: unknown) => + JSON.stringify(value, (key, value) => (key === "pos" || key === "end" ? undefined : value)); + for (const [expression, expected] of [ + ["- - -x", "- - -x"], + ["+ + +x", "+ + +x"], + ["- --x", "- --x"], + ["+ ++x", "+ ++x"], + ["- -x--", "- -x--"], + ["+ +x++", "+ +x++"], + ["- -2", "- -2"], + ["+ +2", "+ +2"], + ["- +x", "-+x"], + ["+ -x", "+-x"], + ["- ++x", "-++x"], + ["+ --x", "+--x"], + ["++ x", "++x"], + ["-- x", "--x"], + ["x ++", "x++"], + ["x --", "x--"], + ["-(-x)", "-(-x)"], + ["+(+x)", "+(+x)"], + ["~ - -x", "~- -x"], + ["! + +x", "! + +x"], + ]) { + const original = parse(`(( ${expression} ))`); + const printed = print(original); + assert.equal(printed, `(( ${expected} ))`, expression); + const reparsed = parse(printed); + assert.equal( + shape(nodeOfType(reparsed.commands[0].command, "ArithmeticCommand").expression), + shape(nodeOfType(original.commands[0].command, "ArithmeticCommand").expression), + expression, + ); + assert.equal(original.errors, undefined, expression); + assert.equal(reparsed.errors, undefined, expression); + assert.equal(print(reparsed), printed, expression); + } +}); + // --- Arithmetic for --- test("arithmetic for loop", () => { @@ -337,6 +393,24 @@ test("arithmetic for negation keeps history expansion inactive in every expressi assert.equal(fmt(expected), expected); }); +test("arithmetic for preserves unary token boundaries in every expression", () => { + const source = "for ((i=- -2; + +i<4; i+=+ +1)); do :; done"; + const original = parse(source); + const printed = print(original); + assert.equal(printed, "for (( i = - -2; + +i < 4; i += + +1 )); do\n :\ndone"); + const reparsed = parse(printed); + const before = nodeOfType(original.commands[0].command, "ArithmeticFor"); + const after = nodeOfType(reparsed.commands[0].command, "ArithmeticFor"); + const shape = (value: unknown) => + JSON.stringify(value, (key, value) => (key === "pos" || key === "end" ? undefined : value)); + for (const field of ["initialize", "test", "update"] as const) { + assert.equal(shape(after[field]), shape(before[field]), field); + } + assert.equal(original.errors, undefined); + assert.equal(reparsed.errors, undefined); + assert.equal(print(reparsed), printed); +}); + // --- Coproc --- test("coproc with name", () => {