Skip to content

Commit b9b0e8c

Browse files
committed
fix(shell): distinguish array assignments from command arguments
1 parent 6836a71 commit b9b0e8c

2 files changed

Lines changed: 72 additions & 0 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,10 @@ describe('code placeholder compiler', () => {
10281028
'cat <<EOF\n$[ {{KEY}} * 2 ]\nEOF',
10291029
'values[{{KEY}}]=x',
10301030
'values[{{KEY}}]+=x',
1031+
'PREFIX=1 values[{{KEY}}]=x',
1032+
'if values[{{KEY}}]=x; then :; fi',
1033+
'declare -a values[{{KEY}}]=x',
1034+
'printf %s done # end of command\nvalues[{{KEY}}]=x',
10311035
'values[ 1 + {{KEY}} ]=x',
10321036
'values[$(printf %s "{{KEY}}")]=x',
10331037
'values=([{{KEY}}]=x)',
@@ -1092,6 +1096,21 @@ describe('code placeholder compiler', () => {
10921096
)
10931097
})
10941098

1099+
it('keeps array-shaped command arguments literal', async () => {
1100+
const compiled = await compileCodePlaceholders({
1101+
code: [
1102+
'printf "%s\\n" config[{{KEY}}]=1',
1103+
'PREFIX=1 printf "%s\\n" config[{{KEY}}]+=2',
1104+
'if true; then printf "%s\\n" config[{{KEY}}]=3; fi',
1105+
].join('\n'),
1106+
language: CodeLanguage.Shell,
1107+
environmentVariables: { KEY: 'word' },
1108+
})
1109+
expect(executeShell(compiled.code, compiled.bindings)).toBe(
1110+
'config[word]=1\nconfig[word]+=2\nconfig[word]=3\n'
1111+
)
1112+
})
1113+
10951114
it.each([
10961115
['unquoted', "cat <<EOF >/dev/null\nToday's report\nEOF"],
10971116
['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"],

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ interface ShellScanFrame {
3333
/** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */
3434
arrayAssignment?: boolean
3535
arithmeticParent?: ShellScanFrame
36+
wordStart?: number
37+
commandPosition?: boolean
38+
declarationCommand?: boolean
39+
redirectionTarget?: boolean
3640
literalRoot: boolean
3741
}
3842

@@ -139,6 +143,7 @@ function readShellArrayStart(
139143
parent.quote === 'none' &&
140144
!parent.literalRoot &&
141145
wordStart &&
146+
(parent.kind === 'array' || parent.commandPosition !== false || parent.declarationCommand) &&
142147
(hasName || (character === '[' && parent.kind === 'array'))
143148
if (!parameter && !assignment) return undefined
144149

@@ -282,6 +287,52 @@ function readHeredocDelimiterWord(
282287
return consumed ? { delimiter, quoted, end: cursor } : undefined
283288
}
284289

290+
/** Assignment-shaped words after an ordinary command name are arguments, not arithmetic. */
291+
function trackShellCommandPosition(code: string, index: number, frame: ShellScanFrame): void {
292+
if (
293+
frame.literalRoot ||
294+
frame.quote !== 'none' ||
295+
(frame.kind !== 'root' && frame.kind !== 'command' && frame.kind !== 'backtick')
296+
) {
297+
return
298+
}
299+
const character = code[index]
300+
const redirection = character === '<' || character === '>' || code.startsWith('&>', index)
301+
const separator =
302+
/[;\n&|()]/.test(character) &&
303+
!redirection &&
304+
!((character === '&' || character === '|') && /[<>]/.test(code[index - 1] ?? ''))
305+
if (!separator && !redirection && !/\s/.test(character)) {
306+
if (character === '\\' && /^\r?\n/.test(code.slice(index + 1, index + 3))) return
307+
frame.wordStart ??= index
308+
return
309+
}
310+
311+
if (frame.wordStart !== undefined) {
312+
const word = code.slice(frame.wordStart, index).replace(/\\\r?\n/g, '')
313+
if (frame.redirectionTarget) {
314+
frame.redirectionTarget = false
315+
} else if (
316+
!(redirection && /^\d+$/.test(word)) &&
317+
frame.commandPosition !== false &&
318+
!/^[A-Za-z_][A-Za-z0-9_]*(?:\+?=|\[[\s\S]*\]\+?=)/.test(word) &&
319+
!/^(?:if|then|else|elif|while|until|do|!|\{|time|command|builtin|exec)$/.test(word)
320+
) {
321+
frame.commandPosition = false
322+
const name = readHeredocDelimiterWord(word, 0, word.length)?.delimiter
323+
frame.declarationCommand = /^(?:declare|export|local|readonly|typeset)$/.test(name ?? '')
324+
}
325+
frame.wordStart = undefined
326+
}
327+
if (separator) {
328+
frame.commandPosition = true
329+
frame.declarationCommand = false
330+
frame.redirectionTarget = false
331+
} else if (redirection) {
332+
frame.redirectionTarget = true
333+
}
334+
}
335+
285336
function parseHeredocHeaders(
286337
code: string,
287338
lineStart: number,
@@ -536,6 +587,7 @@ function collectShellOccurrenceContexts(
536587
const frame = frames.at(-1)
537588
if (!frame) break
538589

590+
trackShellCommandPosition(code, index, frame)
539591
const occurrence = occurrenceByStart.get(index)
540592
if (occurrence) {
541593
contexts.set(occurrence, {
@@ -614,6 +666,7 @@ function collectShellOccurrenceContexts(
614666
}
615667
if (!frame.literalRoot && shellCommentStarts(code, index)) {
616668
const newline = code.indexOf('\n', index)
669+
if (newline !== -1 && newline < end) trackShellCommandPosition(code, newline, frame)
617670
index = newline === -1 || newline >= end ? end : newline + 1
618671
continue
619672
}

0 commit comments

Comments
 (0)