Skip to content

Commit d234578

Browse files
committed
fix(code-placeholders): treat a declaration as pending inside its own command's expansions
1 parent bdcd953 commit d234578

2 files changed

Lines changed: 41 additions & 5 deletions

File tree

‎apps/sim/lib/execution/code-placeholders/compiler.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,11 @@ describe('code placeholder compiler', () => {
10621062
'declare -i n; unset -f n; n={{KEY}}',
10631063
'declare -i n; n=1\\ {{KEY}}',
10641064
'declare -i n; cat <<PAYLOAD\n$(n={{KEY}})\nPAYLOAD',
1065+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1066+
'declare -A m <<PAYLOAD\n${m[{{KEY}}]}\nPAYLOAD',
1067+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1068+
'declare -A m "${m[{{KEY}}]}"',
1069+
'declare -i n; declare +i n <<PAYLOAD\n$(n={{KEY}})\nPAYLOAD',
10651070
'if [[ 0 -lt {{KEY}} ]]; then echo positive; fi',
10661071
'[[ -n x && ( "{{KEY}}" -ge 1 ) ]]',
10671072
'[[ $(printf "%s" "{{KEY}}") -ne 0 ]]',
@@ -1197,6 +1202,7 @@ describe('code placeholder compiler', () => {
11971202
'declare -i n=1 >"{{KEY}}"',
11981203
'[[ "{{KEY}}" == "-eq" ]]',
11991204
'declare -i n; n=1 {{KEY}} hello',
1205+
'declare -A m=([{{KEY}}]=x)',
12001206
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
12011207
'declare -A m=([hello]=world); cat <<PAYLOAD\n${m[{{KEY}}]}\nPAYLOAD',
12021208
])('compiles parameter and prefix text that is not an arithmetic operand: %s', async (code) => {

‎apps/sim/lib/execution/code-placeholders/shell.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ interface ShellCommandScan {
9494
nameArgumentBuiltin?: boolean
9595
/** An `unset -f` was read, so its arguments name functions and leave variable attributes alone. */
9696
unsetsFunctions?: boolean
97+
/**
98+
* Names whose attributes this command changes. Bash expands the command's words and heredocs
99+
* before running it, so an expansion inside the command cannot rely on them yet.
100+
*/
101+
declaredNames?: Set<string>
97102
/** True once the command word (past any `name=value` assignment prefix) has been read. */
98103
sawCommandWord: boolean
99104
/** A non-arithmetic assignment prefix is being read, so a later keyword must not mark its value. */
@@ -799,8 +804,18 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
799804
for (const ended of closed.endedCommands ?? []) enclosingCommand.nested.push(ended)
800805
if (closed.command) enclosingCommand.nested.push(closed.command)
801806
}
807+
/**
808+
* Whether a command still being read, below the innermost `ownFrames` frames, changes `name`'s
809+
* attributes. An expansion inside that command runs before the change, so its attribute is
810+
* unknown there and lookups take the arithmetic side: integer, indexed.
811+
*/
812+
const declaredByEnclosingCommand = (name: string, ownFrames = 1) =>
813+
frames
814+
.slice(0, frames.length - ownFrames)
815+
.some((frame) => frame.command?.declaredNames?.has(name))
802816
/** Whether `name` has the integer attribute here: the nearest scope that sets or clears it wins. */
803817
const declaresInteger = (name: string): boolean => {
818+
if (declaredByEnclosingCommand(name)) return true
804819
for (let depth = frames.length - 1; depth >= 0; depth -= 1) {
805820
const attribute = frames[depth].integerAttribute?.get(name)
806821
if (attribute !== undefined) return attribute
@@ -816,14 +831,22 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
816831
}
817832
for (const name of frame.associativeArrays ?? []) scope.associativeArrays.add(name)
818833
}
834+
// A heredoc body expands before any command still being read runs, its receiver included.
835+
for (const frame of frames) {
836+
for (const name of frame.command?.declaredNames ?? []) {
837+
scope.integerAttribute.set(name, true)
838+
scope.associativeArrays.delete(name)
839+
}
840+
}
819841
return scope
820842
}
821843
/** Whether `name` is a `declare -A` associative array, whose subscript is a string key, not arithmetic. */
822-
const isAssociativeArray = (name: string) =>
844+
const isAssociativeArray = (name: string, ownFrames = 1) =>
845+
!declaredByEnclosingCommand(name, ownFrames) &&
823846
frames.some((frame) => frame.associativeArrays?.has(name))
824847
/** A subscript frame for `name`'s array: a string key for an associative array, else an arithmetic index. */
825-
const subscriptFrameFor = (name: string | undefined) =>
826-
subscriptFrame(isAssociativeArray(name ?? '') ? 'keysubscript' : 'arithmetic')
848+
const subscriptFrameFor = (name: string | undefined, ownFrames = 1) =>
849+
subscriptFrame(isAssociativeArray(name ?? '', ownFrames) ? 'keysubscript' : 'arithmetic')
827850
/** Forget a name's tracked array type in every scope — a re-declaration as `-a`/`-A` resets it. */
828851
const clearArrayType = (name: string) => {
829852
for (const frame of frames) frame.associativeArrays?.delete(name)
@@ -968,6 +991,9 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
968991
// A re-declaration resets the name's array type before this one's attributes apply, so a
969992
// later `declare -a` (indexed) clears an earlier `-A` (associative) and vice versa. The
970993
// integer attribute survives it — only `+i` or `unset` removes that.
994+
if (command.associativeOption || command.indexedOption || command.integerOption) {
995+
;(command.declaredNames ??= new Set()).add(declared)
996+
}
971997
if (command.associativeOption || command.indexedOption) clearArrayType(declared)
972998
if (command.associativeOption) (frame.associativeArrays ??= new Set()).add(declared)
973999
if (command.integerOption) {
@@ -981,7 +1007,10 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
9811007
if (command.nameArgumentBuiltin && command.sawCommandWord) {
9821008
const option = DECLARATION_OPTION.exec(word)
9831009
if (option?.[1] === '-' && option[2].includes('f')) command.unsetsFunctions = true
984-
else if (!command.unsetsFunctions && SHELL_BARE_NAME.test(word)) clearNameType(word)
1010+
else if (!command.unsetsFunctions && SHELL_BARE_NAME.test(word)) {
1011+
;(command.declaredNames ??= new Set()).add(word)
1012+
clearNameType(word)
1013+
}
9851014
}
9861015
// An assignment is one only in command-prefix or declaration-argument position; `echo n=1` or
9871016
// `printf a[i]=1` passes an ordinary string that bash never evaluates.
@@ -1298,7 +1327,8 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
12981327
/[\s(]/.test(code[index - 1] ?? ' ') &&
12991328
opensIndexedAssignment(code, index, end)
13001329
) {
1301-
pushFrame(subscriptFrameFor(frame.arrayName))
1330+
// The literal belongs to the assignment in the frame below, whose own declaration applies.
1331+
pushFrame(subscriptFrameFor(frame.arrayName, 2))
13021332
index += 1
13031333
continue
13041334
}

0 commit comments

Comments
 (0)