Repository: stateful/vscode-runme
Pull request #386 (2023-04-06 21:44:32)

Parse out trailing dollar signs in cmd sequences
Executes sequences like `$ echo Hello` as `echo Hello`

Note that this will affect sequences such as:

```
export VAR="
$
"
```

Though usually `$` are escaped in bash so I imagine this kind of a thing to be rather uncommon.


Patch:
diff --git a/src/client/components/terminal.ts b/src/client/components/terminal.ts
index ef7e0c37..b087d9bf 100644
--- a/src/client/components/terminal.ts
+++ b/src/client/components/terminal.ts
@@ -363,9 +363,11 @@ export class TerminalView extends LitElement {
   }
 
   #updateTerminalTheme(): void {
+    const foregroundColor = this.#getThemeHexColor(terminalCSS('foreground'))
+
     const terminalTheme: ITheme = {
-      foreground: this.#getThemeHexColor(terminalCSS('foreground')),
-      cursor: this.#getThemeHexColor(vscodeCSS('terminalCursor', 'foreground')),
+      foreground: foregroundColor,
+      cursor: this.#getThemeHexColor(vscodeCSS('terminalCursor', 'foreground')) || foregroundColor,
       cursorAccent: this.#getThemeHexColor(vscodeCSS('terminalCursor', 'background')),
       selectionForeground: this.#getThemeHexColor(terminalCSS('selectionForeground')),
       selectionBackground: this.#getThemeHexColor(terminalCSS('selectionBackground')),
diff --git a/src/extension/executors/runner.ts b/src/extension/executors/runner.ts
index 7ce4813e..5e08e06e 100644
--- a/src/extension/executors/runner.ts
+++ b/src/extension/executors/runner.ts
@@ -19,7 +19,7 @@ import { ClientMessages, OutputType } from '../../constants'
 import { CellOutputPayload, ClientMessage } from '../../types'
 import { PLATFORM_OS } from '../constants'
 import { IRunner, IRunnerEnvironment, RunProgramExecution } from '../runner'
-import { getAnnotations, getCmdShellSeq, replaceOutput } from '../utils'
+import { getAnnotations, getCmdShellSeq, prepareCmdSeq, replaceOutput } from '../utils'
 import { postClientMessage } from '../../utils/messaging'
 import { isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
@@ -57,8 +57,8 @@ export async function executeRunner(
     RUNME_ID
   }
 
-  const commands = await parseCommandSeq(cellText)
-  if (!commands) { return false }
+  const commands = await parseCommandSeq(cellText, prepareCmdSeq)
+  if(!commands) { return false }
 
   if (commands.length === 0) {
     commands.push('')
diff --git a/src/extension/executors/utils.ts b/src/extension/executors/utils.ts
index bc1f291d..d9da2d14 100644
--- a/src/extension/executors/utils.ts
+++ b/src/extension/executors/utils.ts
@@ -6,7 +6,7 @@ import { NotebookCellOutput, NotebookCellExecution, NotebookCellOutputItem, wind
 import { ENV_STORE } from '../constants'
 import { OutputType } from '../../constants'
 import type { CellOutputPayload } from '../../types'
-import { replaceOutput, getCmdSeq } from '../utils'
+import { replaceOutput } from '../utils'
 
 const ENV_VAR_REGEXP = /(\$\w+)/g
 /**
@@ -59,7 +59,8 @@ export async function promptUserForVariable(
 }
 
 export function getCommandExportExtractMatches(
-  rawText: string
+  rawText: string,
+  supportsDirect = true
 ): CommandExportExtractMatch[] {
   const test = new RegExp(EXPORT_EXTRACT_REGEX)
 
@@ -84,8 +85,10 @@ export function getCommandExportExtractMatches(
       value = placeHolder.slice(2, -1)
     } else if (!placeHolder.includes('\n')) {
       matchType = 'prompt'
-    } else {
+    } else if (supportsDirect) {
       matchType = 'direct'
+    } else {
+      continue
     }
 
     result.push({
@@ -190,9 +193,12 @@ export function getShellPath(execKey?: string): string|undefined {
  * Returns `undefined` when a user cancels on prompt
  */
 export async function parseCommandSeq(
-  cellText: string
+  cellText: string,
+  parseBlock?: (block: string) => string[]
 ): Promise<string[]|undefined> {
-  const exportMatches = getCommandExportExtractMatches(cellText)
+  parseBlock ??= (s) => s ? s.split('\n') : []
+
+  const exportMatches = getCommandExportExtractMatches(cellText, false)
 
   type CommandBlock =
     |{
@@ -242,5 +248,5 @@ export async function parseCommandSeq(
   parsedCommandBlocks.push({ type: 'block', content: cellText.slice(offset) })
 
   return parsedCommandBlocks
-    .flatMap(({ type, content }) => type === 'block' ? getCmdSeq(content) : [content])
+    .flatMap(({ type, content }) => (type === 'block' && parseBlock?.(content)) || (content ? [content] : []))
 }
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 6719b58a..d72cd0cd 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -150,6 +150,25 @@ export function getCmdSeq(cellText: string): string[] {
     })
 }
 
+/**
+ * Does the following to a command list:
+ *
+ * - Splits by new lines
+ * - Removes trailing `$` characters
+ */
+export function prepareCmdSeq(cellText: string): string[] {
+  return cellText.split('\n')
+    .map(l => {
+      const stripped = l.trimStart()
+
+      if (stripped.startsWith('$')) {
+        return stripped.slice(1).trimStart()
+      }
+
+      return l
+    })
+}
+
 /**
  * treat cells like like a series of individual commands
  * which need to be executed in sequence


Test patch:
diff --git a/tests/extension/executors/utils.test.ts b/tests/extension/executors/utils.test.ts
index 189744d7..3752f473 100644
--- a/tests/extension/executors/utils.test.ts
+++ b/tests/extension/executors/utils.test.ts
@@ -5,6 +5,7 @@ import { expect, vi, test, suite, beforeEach } from 'vitest'
 
 import { ENV_STORE } from '../../../src/extension/constants'
 import { retrieveShellCommand, parseCommandSeq } from '../../../src/extension/executors/utils'
+import { getCmdSeq } from '../../../src/extension/utils'
 
 const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
 
@@ -208,25 +209,71 @@ suite('parseCommandSeq', () => {
     expect(res?.[0]).toBe('export TEST="test value"')
   })
 
+  test('single line export with cancelled prompt', async () => {
+    vi.mocked(window.showInputBox).mockImplementationOnce(async () => undefined)
+
+    const res = await parseCommandSeq([
+      'export TEST="<placeholder>"'
+    ].join('\n'))
+
+    expect(res).toBe(undefined)
+  })
+
   test('multiline export', async () => {
-    const exportLine = [
+    const exportLines = [
       'export TEST="I',
       'am',
       'doing',
       'well!"'
-    ].join('\n')
+    ]
 
-    const res = await parseCommandSeq(exportLine)
+    const res = await parseCommandSeq(exportLines.join('\n'))
 
     expect(res).toBeTruthy
-    expect(res).toHaveLength(1)
-    expect(res?.[0]).toBe(exportLine)
+    expect(res).toHaveLength(4)
+    expect(res).toStrictEqual(exportLines)
   })
 
   test('exports between normal command sequences', async () => {
     vi.mocked(window.showInputBox).mockImplementationOnce(async () => 'test value')
 
-    const cmd = [
+    const cmdLines = [
+      'echo "Hello!"',
+      'echo "Hi!"',
+      'export TEST="<placeholder>"',
+      'echo $TEST',
+      'export TEST_MULTILINE="This',
+      'is',
+      'a',
+      'multiline',
+      'env!"',
+      'echo $TEST_MULTILINE'
+    ]
+
+    const res = await parseCommandSeq(cmdLines.join('\n'))
+
+    expect(res).toBeTruthy()
+    expect(res).toStrictEqual([
+      'echo "Hello!"',
+      'echo "Hi!"',
+      'export TEST="test value"',
+      '',
+      'echo $TEST',
+      ...[
+        'export TEST_MULTILINE="This',
+        'is',
+        'a',
+        'multiline',
+        'env!"',
+      ],
+      'echo $TEST_MULTILINE'
+    ])
+  })
+
+  test('exports between normal command sequences with getCmdSeq', async () => {
+    vi.mocked(window.showInputBox).mockImplementationOnce(async () => 'test value')
+
+    const cmdLines = [
       'echo "Hello!"',
       'echo "Hi!"',
       'export TEST="<placeholder>"',
@@ -237,9 +284,9 @@ suite('parseCommandSeq', () => {
       'multiline',
       'env!"',
       'echo $TEST_MULTILINE'
-    ].join('\n')
+    ]
 
-    const res = await parseCommandSeq(cmd)
+    const res = await parseCommandSeq(cmdLines.join('\n'), getCmdSeq)
 
     expect(res).toBeTruthy()
     expect(res).toStrictEqual([
@@ -247,13 +294,13 @@ suite('parseCommandSeq', () => {
       'echo "Hi!"',
       'export TEST="test value"',
       'echo $TEST',
-      [
+      ...[
         'export TEST_MULTILINE="This',
         'is',
         'a',
         'multiline',
         'env!"',
-      ].join('\n'),
+      ],
       'echo $TEST_MULTILINE'
     ])
   })
diff --git a/tests/extension/utils.test.ts b/tests/extension/utils.test.ts
index b520805d..1d9c793d 100644
--- a/tests/extension/utils.test.ts
+++ b/tests/extension/utils.test.ts
@@ -12,6 +12,7 @@ import {
   mapGitIgnoreToGlobFolders,
   hashDocumentUri,
   getGrpcHost,
+  prepareCmdSeq,
 } from '../../src/extension/utils'
 import { ENV_STORE, DEFAULT_ENV } from '../../src/extension/constants'
 import { CellAnnotations } from '../../src/types'
@@ -289,3 +290,11 @@ suite('#getGrpcHost', () => {
     expect(getGrpcHost()).toStrictEqual('localhost:7863')
   })
 })
+
+suite('prepareCmdSeq', () => {
+  test('should eliminate trailing dollar signs', () => {
+    expect(prepareCmdSeq('$ echo hi')).toStrictEqual([ 'echo hi' ])
+    expect(prepareCmdSeq('  $  echo hi')).toStrictEqual([ 'echo hi' ])
+    expect(prepareCmdSeq('echo 1\necho 2\n $ echo 4')).toStrictEqual(['echo 1', 'echo 2', 'echo 4'])
+  })
+})