Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions src/shared/__tests__/parse-command.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
103 changes: 99 additions & 4 deletions src/shared/parse-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
Loading