Skip to content

Commit bdcd953

Browse files
committed
fix(code-placeholders): end assignment-value scope at the word, carry attributes into heredoc bodies
1 parent f413267 commit bdcd953

2 files changed

Lines changed: 47 additions & 6 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,8 @@ describe('code placeholder compiler', () => {
10601060
'[[ "{{KEY}}" -eq 0 ]] && echo zero',
10611061
'declare -i n; declare -a n; n={{KEY}}',
10621062
'declare -i n; unset -f n; n={{KEY}}',
1063+
'declare -i n; n=1\\ {{KEY}}',
1064+
'declare -i n; cat <<PAYLOAD\n$(n={{KEY}})\nPAYLOAD',
10631065
'if [[ 0 -lt {{KEY}} ]]; then echo positive; fi',
10641066
'[[ -n x && ( "{{KEY}}" -ge 1 ) ]]',
10651067
'[[ $(printf "%s" "{{KEY}}") -ne 0 ]]',
@@ -1194,6 +1196,9 @@ describe('code placeholder compiler', () => {
11941196
'let x=1 > {{KEY}}',
11951197
'declare -i n=1 >"{{KEY}}"',
11961198
'[[ "{{KEY}}" == "-eq" ]]',
1199+
'declare -i n; n=1 {{KEY}} hello',
1200+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1201+
'declare -A m=([hello]=world); cat <<PAYLOAD\n${m[{{KEY}}]}\nPAYLOAD',
11971202
])('compiles parameter and prefix text that is not an arithmetic operand: %s', async (code) => {
11981203
// These forms use the value as pattern, default, or a shielded prefix — never arithmetic — so a
11991204
// conservative scanner must not reject them. Each would fail compilation if wrongly rejected.

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,16 @@ interface ShellCommandScan {
106106
inRedirectTarget: boolean
107107
}
108108

109+
/** The variable attributes visible at a point in the scan, which a heredoc body read there inherits. */
110+
interface ShellAttributeScope {
111+
integerAttribute: Map<string, boolean>
112+
associativeArrays: Set<string>
113+
}
114+
109115
interface ShellOccurrenceContext {
110116
quote: ShellQuote
117+
/** On a heredoc operator, the attributes in scope there, so its body scan sees the same arrays. */
118+
scope?: ShellAttributeScope
111119
/** Its value reaches an arithmetic evaluation — a frame, or a command marked arithmetic later. */
112120
arithmetic?: boolean
113121
unsupported?: 'escaped sequence'
@@ -738,7 +746,8 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
738746
end: number,
739747
literalRoot: boolean,
740748
skippedRanges: Array<[number, number]> = [],
741-
operatorStarts: ReadonlySet<number> = new Set()
749+
operatorStarts: ReadonlySet<number> = new Set(),
750+
inheritedScope?: ShellAttributeScope
742751
): Map<T, ShellOccurrenceContext> {
743752
const occurrenceByStart = new Map(
744753
occurrencesWithin(occurrences, start, end).map(
@@ -747,7 +756,14 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
747756
)
748757
const contexts = new Map<T, ShellOccurrenceContext>()
749758
const frames: ShellScanFrame[] = [
750-
{ kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot },
759+
{
760+
kind: 'root',
761+
quote: 'none',
762+
parenthesisDepth: 0,
763+
literalRoot,
764+
integerAttribute: new Map(inheritedScope?.integerAttribute),
765+
associativeArrays: new Set(inheritedScope?.associativeArrays),
766+
},
751767
]
752768
let skippedRangeIndex = 0
753769
/**
@@ -791,6 +807,17 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
791807
}
792808
return false
793809
}
810+
/** The attributes visible here, outermost scope first so a nearer one overrides an integer flag. */
811+
const visibleScope = (): ShellAttributeScope => {
812+
const scope: ShellAttributeScope = { integerAttribute: new Map(), associativeArrays: new Set() }
813+
for (const frame of frames) {
814+
for (const [name, attribute] of frame.integerAttribute ?? []) {
815+
scope.integerAttribute.set(name, attribute)
816+
}
817+
for (const name of frame.associativeArrays ?? []) scope.associativeArrays.add(name)
818+
}
819+
return scope
820+
}
794821
/** Whether `name` is a `declare -A` associative array, whose subscript is a string key, not arithmetic. */
795822
const isAssociativeArray = (name: string) =>
796823
frames.some((frame) => frame.associativeArrays?.has(name))
@@ -1012,6 +1039,7 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
10121039
const operatorContext: ShellOccurrenceContext = {
10131040
quote: 'none',
10141041
arithmetic: enclosingArithmetic,
1042+
scope: visibleScope(),
10151043
}
10161044
contexts.set(occurrence, operatorContext)
10171045
if (sub >= 0) commandScanOf(frames[sub - 1]).contexts.push(operatorContext)
@@ -1034,10 +1062,15 @@ function collectShellOccurrenceContexts<T extends ShellSpan>(
10341062
}
10351063

10361064
const character = code[index]
1037-
// The redirect-target flag covers only its one word; unquoted whitespace ends that word, so a
1038-
// following argument (`let x=1 >/dev/null {{x}}`) is marked by the command again.
1039-
if (frame.command?.inRedirectTarget && frame.quote === 'none' && /\s/.test(character)) {
1065+
// The redirect-target and assignment-value flags cover only their one word; unquoted whitespace
1066+
// ends that word. `scanWord` clears them at the next word too, but a placeholder-only word never
1067+
// reaches it, so `let x=1 >/dev/null {{x}}` is marked by the command again while the command name
1068+
// in `n=1 {{x}} arg` is not read as `n`'s integer value.
1069+
if (frame.command && frame.quote === 'none' && /\s/.test(character)) {
10401070
frame.command.inRedirectTarget = false
1071+
frame.command.valueArithmetic = false
1072+
frame.command.pendingValue = false
1073+
frame.command.inPrefixValue = false
10411074
}
10421075
// Classify each command word before the quote branches consume a quote-initial word such as
10431076
// `"-i"` or `'let'`. `scanWord` only sets state, never advances `index`; the characters below
@@ -1628,7 +1661,10 @@ export async function compileShellPlaceholders(
16281661
bodyOccurrences,
16291662
heredoc.bodyStart,
16301663
heredoc.bodyEnd,
1631-
true
1664+
true,
1665+
[],
1666+
undefined,
1667+
rootContexts.get(heredocOperators[heredocIndex])?.scope
16321668
)
16331669
for (const occurrence of bodyOccurrences) {
16341670
const edit = resolveInContext(occurrence, bodyContexts.get(occurrence), false)

0 commit comments

Comments
 (0)