import Foundation nonisolated struct ParsedShellLine: Sendable { var commands: [String] var pipelineGroups: [[String]] var substitutions: [String] var redirections: [ShellOutputRedirection] var isAmbiguous: Bool } nonisolated struct ShellOutputRedirection: Sendable { enum Mode: Sendable { case append case truncate case force } var mode: Mode var target: String } nonisolated struct ShellTokens: Sendable { var values: [String] var isAmbiguous: Bool } /// A deliberately bounded shell lexer. It recognizes the composition forms the /// classifier needs without pretending to be a complete POSIX shell parser. /// Anything it cannot balance is reported as ambiguous and therefore cannot be /// classified read-only. nonisolated enum ShellCommandSyntax { static func parse(_ input: String) -> ParsedShellLine { let split = splitCommands(input) let substitutions = commandSubstitutions(in: input) let redirections = outputRedirections(in: input) return ParsedShellLine( commands: split.commands, pipelineGroups: pipelineGroups(commands: split.commands, connectors: split.connectors), substitutions: substitutions.values, redirections: redirections.values, isAmbiguous: split.isAmbiguous || substitutions.isAmbiguous || redirections.isAmbiguous ) } static func tokenize(_ input: String) -> ShellTokens { let characters = Array(input) var tokens: [String] = [] var current = "" var quote: Quote = .none var escaped = false var substitutionDepth = 0 var inBackticks = false var isAmbiguous = false var index = 0 func finishToken() { if !current.isEmpty { tokens.append(current) current = "" } } while index < characters.count { let character = characters[index] if escaped { current.append(character) escaped = false index += 1 continue } if character == "\\" && quote != .single { escaped = true index += 1 continue } if character == "`" && quote != .single { inBackticks.toggle() current.append(character) index += 1 continue } if !inBackticks { if character == "'" && quote != .double { quote = quote == .single ? .none : .single index += 1 continue } if character == "\"" && quote != .single { quote = quote == .double ? .none : .double index += 1 continue } } if quote != .single, character == "$", characterAt(index + 1, in: characters) == "(" { substitutionDepth += 1 current.append("$(") index += 2 continue } if substitutionDepth > 0 { if character == "(" { substitutionDepth += 1 } if character == ")" { substitutionDepth -= 1 } current.append(character) index += 1 continue } if character.isWhitespace && quote == .none && !inBackticks { finishToken() } else { current.append(character) } index += 1 } if escaped || quote != .none || substitutionDepth != 0 || inBackticks { isAmbiguous = true } finishToken() return ShellTokens(values: tokens, isAmbiguous: isAmbiguous) } static func isEnvironmentAssignment(_ token: String) -> Bool { guard let equals = token.firstIndex(of: "=") else { return false } let name = token[token.startIndex.. String { var value = token while value.first == "(" || value.first == "{" { value.removeFirst() } while value.last == ")" || value.last == "}" { value.removeLast() } return String(value.split(separator: "/").last ?? Substring(value)) } private static func splitCommands( _ input: String ) -> (commands: [String], connectors: [Connector], isAmbiguous: Bool) { let characters = Array(input) var commands: [String] = [] var connectors: [Connector] = [] var current = "" var quote: Quote = .none var escaped = false var substitutionDepth = 0 var inBackticks = false var index = 0 func finishCommand() -> Bool { let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { current = "" return false } commands.append(trimmed) current = "" return true } while index < characters.count { let character = characters[index] let next = characterAt(index + 1, in: characters) if escaped { current.append("\\") current.append(character) escaped = false index += 1 continue } if character == "\\" && quote != .single { escaped = true index += 1 continue } if character == "`" && quote != .single { inBackticks.toggle() current.append(character) index += 1 continue } if !inBackticks { if character == "'" && quote != .double { quote = quote == .single ? .none : .single current.append(character) index += 1 continue } if character == "\"" && quote != .single { quote = quote == .double ? .none : .double current.append(character) index += 1 continue } } if quote != .single, !inBackticks, character == "$", next == "(" { substitutionDepth += 1 current.append("$(") index += 2 continue } if substitutionDepth > 0 { if character == "(" { substitutionDepth += 1 } if character == ")" { substitutionDepth -= 1 } current.append(character) index += 1 continue } if quote == .none && !inBackticks { let beginsComment = character == "#" && (current.isEmpty || current.last?.isWhitespace == true) if beginsComment { while index < characters.count, characters[index] != "\n" { index += 1 } if finishCommand() { connectors.append(.boundary) } if index < characters.count { index += 1 } continue } if character == "\n" || character == ";" { if finishCommand() { connectors.append(.boundary) } index += 1 continue } if character == "&", next == ">" { current.append(character) index += 1 continue } if character == "&", current.last == ">" { current.append(character) index += 1 continue } if character == "&" || character == "|" { if finishCommand() { connectors.append(character == "|" && next != "|" ? .pipe : .boundary) } if next == character || (character == "|" && next == "&") { index += 2 } else { index += 1 } continue } } current.append(character) index += 1 } _ = finishCommand() if !commands.isEmpty, connectors.count >= commands.count { connectors.removeLast(connectors.count - commands.count + 1) } return (commands, connectors, escaped || quote != .none || substitutionDepth != 0 || inBackticks) } private static func pipelineGroups(commands: [String], connectors: [Connector]) -> [[String]] { guard let first = commands.first else { return [] } var groups: [[String]] = [[first]] for (index, connector) in connectors.enumerated() where commands.indices.contains(index + 1) { if connector == .pipe { groups[groups.count - 1].append(commands[index + 1]) } else { groups.append([commands[index + 1]]) } } return groups } private static func commandSubstitutions(in input: String) -> (values: [String], isAmbiguous: Bool) { let characters = Array(input) var values: [String] = [] var quote: Quote = .none var escaped = false var index = 0 var isAmbiguous = false while index < characters.count { let character = characters[index] if escaped { escaped = false index += 1 continue } if character == "\\" && quote != .single { escaped = true index += 1 continue } if character == "'" && quote != .double { quote = quote == .single ? .none : .single index += 1 continue } if character == "\"" && quote != .single { quote = quote == .double ? .none : .double index += 1 continue } guard quote != .single else { index += 1 continue } let next = characterAt(index + 1, in: characters) let afterNext = characterAt(index + 2, in: characters) let isDollarSubstitution = character == "$" && next == "(" && afterNext != "(" let isProcessSubstitution = (character == "<" || character == ">") && next == "(" if isDollarSubstitution || isProcessSubstitution { let openIndex = index + 1 if let match = matchingParenthesis(from: openIndex, in: characters) { values.append(String(characters[(openIndex + 1).. (values: [ShellOutputRedirection], isAmbiguous: Bool) { let characters = Array(input) var values: [ShellOutputRedirection] = [] var quote: Quote = .none var escaped = false var index = 0 var isAmbiguous = false while index < characters.count { let current = characters[index] if escaped { escaped = false index += 1 continue } if current == "\\" && quote != .single { escaped = true index += 1 continue } if current == "'" && quote != .double { quote = quote == .single ? .none : .single index += 1 continue } if current == "\"" && quote != .single { quote = quote == .double ? .none : .double index += 1 continue } guard quote == .none else { index += 1 continue } let next = characterAt(index + 1, in: characters) if (current == "$" && next == "(") || ((current == "<" || current == ">") && next == "(") { if let match = matchingParenthesis(from: index + 1, in: characters) { index = match + 1 continue } isAmbiguous = true break } var operatorIndex = index if current == "&", next == ">" { operatorIndex += 1 } else if current == "<", next == ">" { operatorIndex += 1 } else if current != ">" { index += 1 continue } let following = characterAt(operatorIndex + 1, in: characters) if following == "&" || following == "(" { index = operatorIndex + 2 continue } let mode: ShellOutputRedirection.Mode var targetIndex: Int if following == ">" { mode = .append targetIndex = operatorIndex + 2 } else if following == "|" { mode = .force targetIndex = operatorIndex + 2 } else { mode = .truncate targetIndex = operatorIndex + 1 } while targetIndex < characters.count, characters[targetIndex].isWhitespace { targetIndex += 1 } let target = readWord(from: targetIndex, in: characters) if target.value.isEmpty { isAmbiguous = true } else { values.append(ShellOutputRedirection(mode: mode, target: target.value)) targetIndex = target.endIndex } index = max(index + 1, targetIndex) } if escaped || quote != .none { isAmbiguous = true } return (values, isAmbiguous) } private static func matchingParenthesis(from openIndex: Int, in characters: [Character]) -> Int? { var depth = 1 var quote: Quote = .none var escaped = false var index = openIndex + 1 while index < characters.count { let character = characters[index] if escaped { escaped = false index += 1 continue } if character == "\\" && quote != .single { escaped = true index += 1 continue } if character == "'" && quote != .double { quote = quote == .single ? .none : .single } else if character == "\"" && quote != .single { quote = quote == .double ? .none : .double } else if quote == .none { if character == "(" { depth += 1 } if character == ")" { depth -= 1 if depth == 0 { return index } } } index += 1 } return nil } private static func readWord(from start: Int, in characters: [Character]) -> (value: String, endIndex: Int) { var value = "" var quote: Quote = .none var escaped = false var index = start while index < characters.count { let character = characters[index] if escaped { value.append(character) escaped = false index += 1 continue } if character == "\\" && quote != .single { escaped = true index += 1 continue } if character == "'" && quote != .double { quote = quote == .single ? .none : .single index += 1 continue } if character == "\"" && quote != .single { quote = quote == .double ? .none : .double index += 1 continue } if quote == .none && (character.isWhitespace || ";|&".contains(character)) { break } value.append(character) index += 1 } return (value, index) } private static func characterAt(_ index: Int, in characters: [Character]) -> Character? { guard characters.indices.contains(index) else { return nil } return characters[index] } private enum Quote { case none case single case double } private enum Connector { case pipe case boundary } }