diff --git a/src/shared/__tests__/parse-command.spec.ts b/src/shared/__tests__/parse-command.spec.ts new file mode 100644 index 00000000000..85e06f2fe63 --- /dev/null +++ b/src/shared/__tests__/parse-command.spec.ts @@ -0,0 +1,231 @@ +import { parseCommand, joinQuotedLines } from "../parse-command" + +describe("joinQuotedLines", () => { + it("should return empty array for empty input", () => { + expect(joinQuotedLines("")).toEqual([]) + expect(joinQuotedLines(null as any)).toEqual([]) + expect(joinQuotedLines(undefined as any)).toEqual([]) + }) + + it("should handle single line commands without quotes", () => { + expect(joinQuotedLines("echo hello")).toEqual(["echo hello"]) + expect(joinQuotedLines("git status")).toEqual(["git status"]) + }) + + it("should split multiple lines without quotes", () => { + expect(joinQuotedLines("echo hello\necho world")).toEqual(["echo hello", "echo world"]) + expect(joinQuotedLines("git status\ngit log")).toEqual(["git status", "git log"]) + }) + + it("should keep multiline double-quoted strings together", () => { + const input = 'bd create "This is a\nmultiline description"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should keep multiline single-quoted strings together", () => { + const input = "bd create 'This is a\nmultiline description'" + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle Windows line endings (CRLF) within quotes", () => { + const input = 'bd create "This is a\r\nmultiline description"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle Windows line endings (CRLF) between commands", () => { + const input = "echo hello\r\necho world" + expect(joinQuotedLines(input)).toEqual(["echo hello", "echo world"]) + }) + + it("should handle old Mac line endings (CR) within quotes", () => { + const input = 'bd create "This is a\rmultiline description"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle multiple newlines within quotes", () => { + const input = 'bd create "Line 1\nLine 2\nLine 3"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle quoted strings followed by newline and another command", () => { + const input = 'bd create "multiline\ndesc"\necho done' + expect(joinQuotedLines(input)).toEqual(['bd create "multiline\ndesc"', "echo done"]) + }) + + it("should handle escaped quotes within double-quoted strings", () => { + const input = 'echo "hello \\"world\\"\nline 2"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle single quotes within double quotes", () => { + const input = 'echo "it\'s\na test"' + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should handle double quotes within single quotes", () => { + // Single quotes preserve everything literally + const input = "echo 'He said \"hello\"\nworld'" + expect(joinQuotedLines(input)).toEqual([input]) + }) + + it("should skip empty lines between commands", () => { + expect(joinQuotedLines("echo hello\n\necho world")).toEqual(["echo hello", "echo world"]) + expect(joinQuotedLines("echo hello\n \necho world")).toEqual(["echo hello", "echo world"]) + }) + + it("should handle complex multiline commands with command chaining", () => { + const input = 'bd create "desc\nmore" && npm install\necho done' + expect(joinQuotedLines(input)).toEqual(['bd create "desc\nmore" && npm install', "echo done"]) + }) + + it('should handle escaped backslash before closing quote (\\\\" sequence)', () => { + // \\" means escaped backslash (\\) followed by closing quote (") + // The string should end at the quote, not continue + const input = 'echo "hello\\\\"\necho done' + expect(joinQuotedLines(input)).toEqual(['echo "hello\\\\"', "echo done"]) + }) + + it('should handle escaped backslash followed by escaped quote (\\\\\\" sequence)', () => { + // \\\" means escaped backslash (\\) followed by escaped quote (\") + // The string should continue past the quote + const input = 'echo "hello\\\\\\"world"\necho done' + expect(joinQuotedLines(input)).toEqual(['echo "hello\\\\\\"world"', "echo done"]) + }) +}) + +describe("parseCommand", () => { + it("should return empty array for empty input", () => { + expect(parseCommand("")).toEqual([]) + expect(parseCommand(" ")).toEqual([]) + expect(parseCommand(null as any)).toEqual([]) + expect(parseCommand(undefined as any)).toEqual([]) + }) + + it("should parse simple single command", () => { + expect(parseCommand("echo hello")).toEqual(["echo hello"]) + expect(parseCommand("git status")).toEqual(["git status"]) + }) + + it("should parse command chain with &&", () => { + expect(parseCommand("npm install && npm test")).toEqual(["npm install", "npm test"]) + }) + + it("should parse command chain with ||", () => { + expect(parseCommand("npm test || echo failed")).toEqual(["npm test", "echo failed"]) + }) + + it("should parse command chain with ;", () => { + expect(parseCommand("echo hello; echo world")).toEqual(["echo hello", "echo world"]) + }) + + it("should parse commands on separate lines", () => { + expect(parseCommand("echo hello\necho world")).toEqual(["echo hello", "echo world"]) + }) + + it("should handle multiline double-quoted strings as single command", () => { + const input = 'bd create "This is a\nmultiline description"' + const result = parseCommand(input) + expect(result).toEqual(['bd create "This is a\nmultiline description"']) + }) + + it("should handle multiline single-quoted strings as single command", () => { + const input = "bd create 'This is a\nmultiline description'" + const result = parseCommand(input) + // Note: shell-quote strips single quotes, but the multiline content stays together + expect(result.length).toBe(1) + expect(result[0]).toContain("bd create") + expect(result[0]).toContain("This is a") + expect(result[0]).toContain("multiline description") + }) + + it("should handle multiline quoted string with command chain on same line", () => { + const input = 'bd create "desc\nmore" && echo done' + const result = parseCommand(input) + expect(result).toEqual(['bd create "desc\nmore"', "echo done"]) + }) + + it("should handle multiline quoted string followed by newline command", () => { + const input = 'bd create "desc\nmore"\necho done' + const result = parseCommand(input) + expect(result).toEqual(['bd create "desc\nmore"', "echo done"]) + }) + + it("should handle real-world beads create command", () => { + const input = `bd create "This is the first line. +This is the second line. +This is the third line."` + const result = parseCommand(input) + expect(result.length).toBe(1) + expect(result[0]).toContain("bd create") + expect(result[0]).toContain("first line") + expect(result[0]).toContain("third line") + }) + + it("should handle beads command followed by another command", () => { + const input = `bd create "Multiline +description" && npm install` + const result = parseCommand(input) + expect(result).toEqual(['bd create "Multiline\ndescription"', "npm install"]) + }) + + it("should handle multiple multiline quoted strings in sequence", () => { + const input = 'echo "Line 1\nLine 2" && echo "Line 3\nLine 4"' + const result = parseCommand(input) + expect(result).toEqual(['echo "Line 1\nLine 2"', 'echo "Line 3\nLine 4"']) + }) + + it("should handle Windows line endings in multiline quotes", () => { + const input = 'bd create "Line 1\r\nLine 2"' + const result = parseCommand(input) + expect(result).toEqual(['bd create "Line 1\r\nLine 2"']) + }) + + it("should preserve variable references", () => { + const input = 'echo $HOME && echo "path: $PATH"' + const result = parseCommand(input) + expect(result.length).toBe(2) + expect(result[0]).toContain("$HOME") + expect(result[1]).toContain("$PATH") + }) + + it("should handle empty lines in the input", () => { + const input = "echo hello\n\necho world" + const result = parseCommand(input) + expect(result).toEqual(["echo hello", "echo world"]) + }) +}) + +describe("parseCommand - auto-approval validation scenarios", () => { + // These tests verify the fix for issue #10226: + // Multiline commands with quoted strings should be correctly parsed + // for auto-approval validation + + it("should correctly identify bd command prefix with multiline description", () => { + const input = 'bd create "This is a\nmultiline\ndescription"' + const result = parseCommand(input) + + // Should be a single command that starts with "bd" + expect(result.length).toBe(1) + expect(result[0].startsWith("bd ")).toBe(true) + }) + + it("should correctly split bd command from other commands", () => { + const input = 'bd create "desc\nmore" && npm install' + const result = parseCommand(input) + + expect(result.length).toBe(2) + expect(result[0].startsWith("bd ")).toBe(true) + expect(result[1].startsWith("npm ")).toBe(true) + }) + + it("should handle multiline command on separate lines from other commands", () => { + const input = `bd create "multiline +description" +echo done` + const result = parseCommand(input) + + expect(result.length).toBe(2) + expect(result[0].startsWith("bd ")).toBe(true) + expect(result[1]).toBe("echo done") + }) +}) diff --git a/src/shared/parse-command.ts b/src/shared/parse-command.ts index a8d87b76acf..2d82c5c3535 100644 --- a/src/shared/parse-command.ts +++ b/src/shared/parse-command.ts @@ -2,6 +2,101 @@ import { parse } from "shell-quote" export type ShellToken = string | { op: string } | { command: string } +/** + * Join lines that are within quoted strings. + * + * This function scans through the input character by character, tracking + * whether we're inside a quoted string. When we encounter a newline while + * inside quotes, we preserve it rather than splitting the command. + * + * This approach: + * - Preserves the original content exactly (no placeholder replacement/restoration) + * - Properly handles both single and double quotes + * - Handles escaped quotes within strings + * - Handles different line ending formats (CRLF, LF, CR) + * + * @param command - The command string that may contain multiline quoted strings + * @returns Array of logical command lines with multiline quotes joined + */ +export function joinQuotedLines(command: string): string[] { + if (!command) { + return [] + } + + const result: string[] = [] + let currentLine = "" + let inDoubleQuote = false + let inSingleQuote = false + let i = 0 + + while (i < command.length) { + const char = command[i] + + // Handle escape sequences (only in double quotes, single quotes are literal) + // Count consecutive backslashes before the current character + // If odd count, the current character is escaped; if even, it's not + // e.g., \" = escaped quote, \\" = escaped backslash + closing quote + let backslashCount = 0 + if (inDoubleQuote) { + let j = i - 1 + while (j >= 0 && command[j] === "\\") { + backslashCount++ + j-- + } + } + const isEscaped = backslashCount % 2 === 1 + + // Handle quote state changes + if (char === '"' && !inSingleQuote && !isEscaped) { + inDoubleQuote = !inDoubleQuote + currentLine += char + } else if (char === "'" && !inDoubleQuote) { + // Single quotes can't be escaped, so we just toggle + inSingleQuote = !inSingleQuote + currentLine += char + } else if (char === "\r" || char === "\n") { + // Handle different line endings: \r\n, \n, or \r + if (char === "\r" && command[i + 1] === "\n") { + // CRLF - consume both characters + if (inDoubleQuote || inSingleQuote) { + // Inside quotes - preserve the newline in the command + currentLine += "\r\n" + } else { + // Outside quotes - this is a line separator + if (currentLine.trim()) { + result.push(currentLine) + } + currentLine = "" + } + i++ // Skip the \n since we consumed it + } else { + // LF or CR alone + if (inDoubleQuote || inSingleQuote) { + // Inside quotes - preserve the newline in the command + currentLine += char + } else { + // Outside quotes - this is a line separator + if (currentLine.trim()) { + result.push(currentLine) + } + currentLine = "" + } + } + } else { + currentLine += char + } + + i++ + } + + // Don't forget the last line + if (currentLine.trim()) { + result.push(currentLine) + } + + return result +} + /** * Split a command string into individual sub-commands by * chaining operators (&&, ||, ;, |, or &) and newlines. @@ -11,16 +106,16 @@ export type ShellToken = string | { op: string } | { command: string } * - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd)) * - PowerShell redirections (2>&1) * - Chain operators (&&, ||, ;, |, &) - * - Newlines as command separators + * - Newlines as command separators (respecting quoted strings) */ export function parseCommand(command: string): string[] { if (!command?.trim()) { return [] } - // Split by newlines first (handle different line ending formats) - // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) - const lines = command.split(/\r\n|\r|\n/) + // Join lines that are within quoted strings first + // This preserves multiline quoted content as single logical lines + const lines = joinQuotedLines(command) const allCommands: string[] = [] for (const line of lines) {