531 lines
18 KiB
Swift
531 lines
18 KiB
Swift
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..<equals]
|
|
guard let first = name.first, first == "_" || first.isLetter else { return false }
|
|
return name.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber }
|
|
}
|
|
|
|
static func executableName(_ token: String) -> 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)..<match]))
|
|
index = match + 1
|
|
continue
|
|
}
|
|
isAmbiguous = true
|
|
break
|
|
}
|
|
|
|
if character == "`" {
|
|
var content = ""
|
|
var cursor = index + 1
|
|
var backtickEscaped = false
|
|
while cursor < characters.count {
|
|
let candidate = characters[cursor]
|
|
if backtickEscaped {
|
|
content.append(candidate)
|
|
backtickEscaped = false
|
|
} else if candidate == "\\" {
|
|
backtickEscaped = true
|
|
} else if candidate == "`" {
|
|
break
|
|
} else {
|
|
content.append(candidate)
|
|
}
|
|
cursor += 1
|
|
}
|
|
if cursor == characters.count {
|
|
isAmbiguous = true
|
|
break
|
|
}
|
|
values.append(content)
|
|
index = cursor + 1
|
|
continue
|
|
}
|
|
index += 1
|
|
}
|
|
if escaped || quote != .none { isAmbiguous = true }
|
|
return (values, isAmbiguous)
|
|
}
|
|
|
|
private static func outputRedirections(in input: String) -> (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
|
|
}
|
|
}
|