diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 5d5712d..156ecb6 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -467,3 +467,59 @@ export function shellSpawnArgs(configured: string[] = []): string[] { if (configured.length > 0) return [...configured] return process.platform === 'win32' ? [] : ['-l'] } + +/** + * Strip ONE pair of surrounding quotes from a configured shell path. Users + * paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the + * quotes are shell-input syntax, not part of the path. Unpaired quotes and + * shorter values stay verbatim. + */ +export function unquotePath(value: string): string { + if (value.length >= 2) { + const first = value[0] + const last = value[value.length - 1] + if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1) + } + return value +} + +/** + * Split a settings-page shell-arguments string into argv with quote-aware + * grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are + * LITERAL — a backslash is never an escape, so Windows paths survive intact + * (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces). + * The price is that an argument containing a literal quote character cannot + * be expressed; shell startup arguments never need one. An unclosed quote + * folds the remainder into the current token (settings input stays + * forgiving); an empty quote pair yields no argument. + */ +export function splitShellArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + let started = false + for (const ch of input) { + if (quote !== null) { + if (ch === quote) quote = null + else current += ch + continue + } + if (ch === '"' || ch === "'") { + quote = ch + started = true + continue + } + if (/\s/.test(ch)) { + if (started) { + args.push(current) + current = '' + started = false + } + continue + } + current += ch + started = true + } + if (started) args.push(current) + return args.filter(arg => arg !== '') +} diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts index a02cefd..02f80f0 100644 --- a/tests/pty-helpers.spec.ts +++ b/tests/pty-helpers.spec.ts @@ -22,6 +22,8 @@ import { resolveShellExecutable, shellDisplayName, shellSpawnArgs, + splitShellArgs, + unquotePath, } from '../src/pty-manager.ts' describe('pty helpers', () => { @@ -156,4 +158,34 @@ describe('pty helpers', () => { expect(helper).toBeTruthy() expect((statSync(helper!).mode & 0o111) !== 0).toBe(true) }) + + it('unquotes a paired surrounding quote from a configured shell path', () => { + expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"')) + .toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell') + // Unpaired or single characters stay verbatim. + expect(unquotePath('"mismatched')).toBe('"mismatched') + expect(unquotePath('pwsh.exe')).toBe('pwsh.exe') + expect(unquotePath('"')).toBe('"') + expect(unquotePath('')).toBe('') + }) + + it('splits shell args with quote-aware grouping', () => { + expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"')) + .toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1']) + // Single quotes group too, and preserve inner double quotes verbatim. + expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"']) + expect(splitShellArgs(' -l ')).toEqual(['-l']) + expect(splitShellArgs('')).toEqual([]) + expect(splitShellArgs(' ')).toEqual([]) + }) + + it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => { + // Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash. + expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\']) + // An unclosed quote folds the remainder into one token instead of erroring. + expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote']) + // Empty quote pairs produce no empty-string argument. + expect(splitShellArgs('"" x')).toEqual(['x']) + }) })