Skip to content

Commit 75ff1df

Browse files
authored
Parse command substitution in arithmetic expressions (#2)
1 parent 2bf1ff2 commit 75ff1df

7 files changed

Lines changed: 194 additions & 8 deletions

File tree

src/arithmetic.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ArithmeticExpression } from "./types.ts";
1+
import type { ArithmeticCommandExpansion, ArithmeticExpression } from "./types.ts";
22
import {
33
CH_TAB,
44
CH_NL,
@@ -400,14 +400,24 @@ export function parseArithmeticExpression(src: string, offset: number = 0): Arit
400400
}
401401
} else {
402402
// $( command substitution )
403-
pos++;
403+
pos++; // skip (
404404
let depth = 1;
405405
while (pos < len && depth > 0) {
406406
const ch = src.charCodeAt(pos);
407407
if (ch === CH_LPAREN) depth++;
408408
else if (ch === CH_RPAREN) depth--;
409409
pos++;
410410
}
411+
const text = src.slice(start, pos);
412+
const inner = text.slice(2, -1); // remove "$(" and ")"
413+
return {
414+
type: "ArithmeticCommandExpansion",
415+
pos: start + offset,
416+
end: pos + offset,
417+
text,
418+
inner,
419+
script: undefined,
420+
} satisfies ArithmeticCommandExpansion;
411421
}
412422
} else if (c === CH_LBRACE) {
413423
// ${ parameter expansion }

src/parser.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class ArithmeticCommandImpl implements ArithmeticCommand {
6161
get expression(): ArithmeticExpression | undefined {
6262
if (this.#expression === null) {
6363
this.#expression = parseArithmeticExpression(this.body, this.pos + 2) ?? undefined;
64+
if (this.#expression) resolveArithmeticExpansions(this.#expression);
6465
}
6566
return this.#expression;
6667
}
@@ -110,7 +111,10 @@ class ArithmeticForImpl implements ArithmeticFor {
110111
if (this.#initialize === null) {
111112
if (this.#initStr) {
112113
const expr = parseArithmeticExpression(this.#initStr);
113-
if (expr) offsetArith(expr, this.#initPos);
114+
if (expr) {
115+
offsetArith(expr, this.#initPos);
116+
resolveArithmeticExpansions(expr);
117+
}
114118
this.#initialize = expr ?? undefined;
115119
} else {
116120
this.#initialize = undefined;
@@ -126,7 +130,10 @@ class ArithmeticForImpl implements ArithmeticFor {
126130
if (this.#test === null) {
127131
if (this.#testStr) {
128132
const expr = parseArithmeticExpression(this.#testStr);
129-
if (expr) offsetArith(expr, this.#testPos);
133+
if (expr) {
134+
offsetArith(expr, this.#testPos);
135+
resolveArithmeticExpansions(expr);
136+
}
130137
this.#test = expr ?? undefined;
131138
} else {
132139
this.#test = undefined;
@@ -142,7 +149,10 @@ class ArithmeticForImpl implements ArithmeticFor {
142149
if (this.#update === null) {
143150
if (this.#updateStr) {
144151
const expr = parseArithmeticExpression(this.#updateStr);
145-
if (expr) offsetArith(expr, this.#updatePos);
152+
if (expr) {
153+
offsetArith(expr, this.#updatePos);
154+
resolveArithmeticExpansions(expr);
155+
}
146156
this.#update = expr ?? undefined;
147157
} else {
148158
this.#update = undefined;
@@ -198,6 +208,32 @@ function offsetArith(node: ArithmeticExpression, base: number): void {
198208
}
199209
}
200210

211+
export function resolveArithmeticExpansions(expr: ArithmeticExpression): void {
212+
switch (expr.type) {
213+
case "ArithmeticBinary":
214+
resolveArithmeticExpansions(expr.left);
215+
resolveArithmeticExpansions(expr.right);
216+
break;
217+
case "ArithmeticUnary":
218+
resolveArithmeticExpansions(expr.operand);
219+
break;
220+
case "ArithmeticTernary":
221+
resolveArithmeticExpansions(expr.test);
222+
resolveArithmeticExpansions(expr.consequent);
223+
resolveArithmeticExpansions(expr.alternate);
224+
break;
225+
case "ArithmeticGroup":
226+
resolveArithmeticExpansions(expr.expression);
227+
break;
228+
case "ArithmeticCommandExpansion":
229+
if (expr.inner !== undefined) {
230+
expr.script = parse(expr.inner);
231+
expr.inner = undefined;
232+
}
233+
break;
234+
}
235+
}
236+
201237
// Lookup tables for O(1) token classification (replaces sequential comparisons)
202238
const listTerminators = new Uint8Array(37);
203239
listTerminators[Token.EOF] = 1;

src/parts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DeferredCommandExpansion, Word, WordPart } from "./types.ts";
22
import { Lexer } from "./lexer.ts";
3-
import { parse } from "./parser.ts";
3+
import { parse, resolveArithmeticExpansions } from "./parser.ts";
44

55
/**
66
* Compute the structural parts of a word by re-scanning the source.
@@ -25,6 +25,13 @@ export function computeWordParts(source: string, word: Word): WordPart[] | undef
2525
resolveExpansion(exp);
2626
}
2727

28+
// Resolve command substitutions inside arithmetic expressions
29+
for (const part of parts) {
30+
if (part.type === "ArithmeticExpansion" && part.expression) {
31+
resolveArithmeticExpansions(part.expression);
32+
}
33+
}
34+
2835
return parts;
2936
}
3037

src/printer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ function arithExpr(e: ArithmeticExpression): string {
323323
if (arithNeedsParens(e.right, prec, ra ? true : false)) right = "(" + right + ")";
324324
return left + " " + e.operator + " " + right;
325325
}
326+
case "ArithmeticCommandExpansion":
327+
return e.text; // preserve original text for roundtrip
326328
}
327329
}
328330

src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ export type ArithmeticExpression =
9494
| ArithmeticUnary
9595
| ArithmeticTernary
9696
| ArithmeticGroup
97-
| ArithmeticWord;
97+
| ArithmeticWord
98+
| ArithmeticCommandExpansion;
9899

99100
export interface ArithmeticBinary {
100101
type: "ArithmeticBinary";
@@ -137,6 +138,15 @@ export interface ArithmeticWord {
137138
value: string;
138139
}
139140

141+
export interface ArithmeticCommandExpansion {
142+
type: "ArithmeticCommandExpansion";
143+
pos: number;
144+
end: number;
145+
text: string; // e.g., "$(cmd)"
146+
inner: string | undefined; // e.g., "cmd" - cleared after resolution
147+
script: Script | undefined; // set after resolution
148+
}
149+
140150
export type DoubleQuotedChild =
141151
| LiteralPart
142152
| SimpleExpansionPart

test/arithmetic.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parse } from "../src/parser.ts";
55
import { computeWordParts } from "../src/parts.ts";
66
import type {
77
ArithmeticBinary,
8+
ArithmeticCommandExpansion,
89
ArithmeticExpression,
910
ArithmeticGroup,
1011
ArithmeticTernary,
@@ -439,3 +440,85 @@ test("arithmetic expressions parse without errors", () => {
439440
assert.ok(ast.commands.length > 0, `Failed: ${script}`);
440441
}
441442
});
443+
444+
// --- Command substitution in arithmetic ---
445+
446+
test("command substitution in arithmetic - raw parse", () => {
447+
const e = parseArithmeticExpression("$(cmd) + 1")!;
448+
assert.equal(e.type, "ArithmeticBinary");
449+
assert.equal(bin(e).operator, "+");
450+
const left = bin(e).left;
451+
assert.equal(left.type, "ArithmeticCommandExpansion");
452+
assert.equal((left as ArithmeticCommandExpansion).text, "$(cmd)");
453+
assert.equal((left as ArithmeticCommandExpansion).inner, "cmd");
454+
assert.equal((left as ArithmeticCommandExpansion).script, undefined);
455+
});
456+
457+
test("command substitution with argument in arithmetic", () => {
458+
const e = parseArithmeticExpression("$(echo hello) + x")!;
459+
assert.equal(e.type, "ArithmeticBinary");
460+
const left = bin(e).left as ArithmeticCommandExpansion;
461+
assert.equal(left.type, "ArithmeticCommandExpansion");
462+
assert.equal(left.text, "$(echo hello)");
463+
assert.equal(left.inner, "echo hello");
464+
});
465+
466+
test("nested command substitution in arithmetic", () => {
467+
const e = parseArithmeticExpression("$(echo $(inner))")!;
468+
assert.equal(e.type, "ArithmeticCommandExpansion");
469+
assert.equal((e as ArithmeticCommandExpansion).text, "$(echo $(inner))");
470+
assert.equal((e as ArithmeticCommandExpansion).inner, "echo $(inner)");
471+
});
472+
473+
test("command substitution at start and end of expression", () => {
474+
const e = parseArithmeticExpression("$(a) + $(b)")!;
475+
assert.equal(e.type, "ArithmeticBinary");
476+
const left = bin(e).left as ArithmeticCommandExpansion;
477+
const right = bin(e).right as ArithmeticCommandExpansion;
478+
assert.equal(left.type, "ArithmeticCommandExpansion");
479+
assert.equal(right.type, "ArithmeticCommandExpansion");
480+
assert.equal(left.text, "$(a)");
481+
assert.equal(right.text, "$(b)");
482+
});
483+
484+
test("command substitution resolved in arithmetic expansion", () => {
485+
const ast = parse("echo $(( $(cmd) + 1 ))");
486+
const parts = computeWordParts("echo $(( $(cmd) + 1 ))", getCmd(ast).suffix[0])!;
487+
const arith = parts[0] as import("../src/types.ts").ArithmeticExpansionPart;
488+
assert.equal(arith.type, "ArithmeticExpansion");
489+
const binary = arith.expression as ArithmeticBinary;
490+
assert.equal(binary.type, "ArithmeticBinary");
491+
const left = binary.left as ArithmeticCommandExpansion;
492+
assert.equal(left.type, "ArithmeticCommandExpansion");
493+
assert.equal(left.inner, undefined); // cleared after resolution
494+
assert.ok(left.script); // now populated
495+
assert.equal(left.script!.commands[0].command.type, "Command");
496+
});
497+
498+
test("command substitution in arithmetic command", () => {
499+
const ast = parse("(( $(cmd) ))");
500+
const arithCmd = ast.commands[0].command as import("../src/types.ts").ArithmeticCommand;
501+
const expr = arithCmd.expression!;
502+
assert.equal(expr.type, "ArithmeticCommandExpansion");
503+
assert.ok((expr as ArithmeticCommandExpansion).script);
504+
});
505+
506+
test("command substitution in arithmetic for loop", () => {
507+
const ast = parse("for (( i = $(start); i < $(limit); i++ )); do echo $i; done");
508+
const forLoop = ast.commands[0].command as ArithmeticFor;
509+
assert.ok(forLoop.initialize);
510+
const initBin = forLoop.initialize as ArithmeticBinary;
511+
assert.equal(initBin.type, "ArithmeticBinary");
512+
assert.equal(initBin.operator, "=");
513+
const initRight = initBin.right as ArithmeticCommandExpansion;
514+
assert.equal(initRight.type, "ArithmeticCommandExpansion");
515+
assert.equal(initRight.text, "$(start)");
516+
assert.ok(initRight.script);
517+
518+
assert.ok(forLoop.test);
519+
const testBin = forLoop.test as ArithmeticBinary;
520+
assert.equal(testBin.type, "ArithmeticBinary");
521+
const testRight = testBin.right as ArithmeticCommandExpansion;
522+
assert.equal(testRight.type, "ArithmeticCommandExpansion");
523+
assert.ok(testRight.script);
524+
});

test/verify.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Walks AST, fills gaps from source, validates content fields against source.
33
// Also verifies word parts: parts.map(p => p.text).join('') === source span.
44

5-
import type { Node, Script, WordPart, DoubleQuotedChild } from "../src/types.ts";
5+
import type { ArithmeticExpression, Node, Script, WordPart, DoubleQuotedChild } from "../src/types.ts";
66
import { computeWordParts } from "../src/parts.ts";
77

88
type AnyNode = { type: string; pos: number; end: number; [k: string]: any };
@@ -76,6 +76,9 @@ function checkContent(src: string, node: AnyNode) {
7676
case "ArithmeticWord":
7777
if (node.value !== span) fail(node, "value", span, node.value);
7878
break;
79+
case "ArithmeticCommandExpansion":
80+
if (node.text !== span) fail(node, "text", span, node.text);
81+
break;
7982
case "ArithmeticCommand":
8083
// body is text between (( and )), source span starts with ((
8184
if (span.startsWith("((") && span.endsWith("))")) {
@@ -157,4 +160,39 @@ function verifyPartChildren(source: string, part: WordPart | DoubleQuotedChild)
157160
);
158161
}
159162
}
163+
164+
if (part.type === "ArithmeticExpansion" && part.expression) {
165+
verifyArithExpansions(part.expression);
166+
}
167+
}
168+
169+
function verifyArithExpansions(e: ArithmeticExpression): void {
170+
switch (e.type) {
171+
case "ArithmeticCommandExpansion":
172+
if (e.script) {
173+
const innerSrc = e.text.slice(2, -1); // remove "$(" and ")"
174+
const rebuilt = _verify(innerSrc, e.script as any);
175+
if (rebuilt !== innerSrc) {
176+
throw new Error(
177+
`ArithmeticCommandExpansion inner script verify failed: expected ${JSON.stringify(innerSrc)}, got ${JSON.stringify(rebuilt)}`,
178+
);
179+
}
180+
}
181+
break;
182+
case "ArithmeticBinary":
183+
verifyArithExpansions(e.left);
184+
verifyArithExpansions(e.right);
185+
break;
186+
case "ArithmeticUnary":
187+
verifyArithExpansions(e.operand);
188+
break;
189+
case "ArithmeticTernary":
190+
verifyArithExpansions(e.test);
191+
verifyArithExpansions(e.consequent);
192+
verifyArithExpansions(e.alternate);
193+
break;
194+
case "ArithmeticGroup":
195+
verifyArithExpansions(e.expression);
196+
break;
197+
}
160198
}

0 commit comments

Comments
 (0)