Files
Termind/Frameworks/TermindDomain/Shell/ShellHistory.swift
T

194 lines
7.4 KiB
Swift

import Foundation
public nonisolated struct ShellHistorySource: Sendable, Equatable {
public enum Shell: String, Sendable { case bash, zsh, fish }
public var shell: Shell
/// nil asks for the shell's default; empty explicitly disables file history.
public var path: String?
public init(shell: Shell, path: String? = nil) {
self.shell = shell
self.path = path
}
}
public nonisolated struct ShellHistoryRecord: Sendable, Equatable {
public var command: String
public var executedAt: Date?
public var duration: TimeInterval?
public init(command: String, executedAt: Date? = nil, duration: TimeInterval? = nil) {
self.command = command
self.executedAt = executedAt
self.duration = duration
}
}
public nonisolated enum ShellHistoryFilter {
public static func isIntegrationCommand(_ command: String) -> Bool {
let text = command.trimmingCharacters(in: .whitespacesAndNewlines)
// Anchored signatures, not substring matches: echo/grep of these names is user work.
if text.hasPrefix("command true '__TRMD_"), text.contains("_PROBE__'") { return true }
if text.hasPrefix("if [[ ") || text.hasPrefix("if not set -q "),
text.contains("__termind_integration_token"),
text.contains("__termind_report_prompt"), text.contains("_DONE__")
{
return true
}
let name = text.prefix { $0.isLetter || $0.isNumber || $0 == "_" }
return [
"__termind_report_command", "__termind_report_prompt", "__termind_report_cwd",
"__termind_report_finished", "__termind_prompt_begin", "__termind_prompt_end",
"__termind_compose_draft", "__termind_compose_clear",
].contains(String(name))
}
}
/// Parses complete records in file order. It never invents timestamps or evaluates shell text.
public nonisolated enum ShellHistoryParser {
public static func parse(
_ text: String, shell: ShellHistorySource.Shell, truncatedStart: Bool = false
) -> [ShellHistoryRecord] {
var lines = text.components(separatedBy: "\n")
// A file being appended may end halfway through a record. Only newline-terminated text is complete.
if !lines.isEmpty { lines.removeLast() }
if truncatedStart, !lines.isEmpty { lines.removeFirst() }
let records: [ShellHistoryRecord]
switch shell {
case .bash: records = bash(lines, truncatedStart: truncatedStart)
case .zsh: records = zsh(lines, truncatedStart: truncatedStart)
case .fish: records = fish(lines)
}
return records.filter {
!$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !ShellHistoryFilter.isIntegrationCommand($0.command)
}
}
private static func timestamp(_ text: String) -> Date? {
guard !text.isEmpty, text.allSatisfy(\.isNumber), let seconds = Double(text),
seconds.isFinite, seconds > 0
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
private static func bash(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
let hasMarkers = lines.contains { $0.hasPrefix("#") && timestamp(String($0.dropFirst())) != nil }
if !hasMarkers { return plain(lines) }
var records: [ShellHistoryRecord] = []
var date: Date?
var command: [String] = []
var accepts = !truncatedStart
func flush() {
if accepts, !command.isEmpty {
records.append(ShellHistoryRecord(command: command.joined(separator: "\n"), executedAt: date))
}
command.removeAll()
}
for line in lines {
if line.hasPrefix("#"), let next = timestamp(String(line.dropFirst())) {
flush()
date = next
accepts = true
} else {
command.append(line)
}
}
flush()
return records
}
private static func plain(_ lines: [String]) -> [ShellHistoryRecord] {
var result: [ShellHistoryRecord] = []
var pending = ""
for line in lines {
pending += line
if trailingBackslashes(line).isMultiple(of: 2) {
result.append(ShellHistoryRecord(command: pending))
pending = ""
} else {
pending += "\n"
}
}
return result
}
private static func trailingBackslashes(_ line: String) -> Int {
line.reversed().prefix { $0 == "\\" }.count
}
private static func zsh(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
var result: [ShellHistoryRecord] = []
var pending: ShellHistoryRecord?
var continuing = false
let extended = lines.contains { header($0) != nil }
var accepts = !truncatedStart || !extended
for line in lines {
if !continuing {
if let parsed = header(line) {
pending = parsed
accepts = true
} else if accepts {
pending = ShellHistoryRecord(command: line)
}
} else {
pending?.command += "\n" + line
}
continuing = !trailingBackslashes(line).isMultiple(of: 2)
if continuing {
// Zsh uses backslash + physical newline to serialize a literal newline.
if pending != nil { pending!.command.removeLast() }
} else if let record = pending {
result.append(record)
pending = nil
}
}
return result
}
private static func header(_ line: String) -> ShellHistoryRecord? {
guard line.hasPrefix(": "), let semicolon = line.firstIndex(of: ";") else { return nil }
let fields = line[line.index(line.startIndex, offsetBy: 2)..<semicolon].split(separator: ":")
guard fields.count == 2, let date = timestamp(String(fields[0])),
let duration = Double(fields[1]), duration.isFinite, duration >= 0
else { return nil }
return ShellHistoryRecord(
command: String(line[line.index(after: semicolon)...]), executedAt: date, duration: duration)
}
private static func fish(_ lines: [String]) -> [ShellHistoryRecord] {
var records: [ShellHistoryRecord] = []
var current: ShellHistoryRecord?
for line in lines {
if line.hasPrefix("- cmd: ") {
if let current { records.append(current) }
current = ShellHistoryRecord(command: fishUnescape(String(line.dropFirst(7))))
} else if line.hasPrefix(" when: ") {
current?.executedAt = timestamp(String(line.dropFirst(8)))
}
}
if let current { records.append(current) }
return records
}
private static func fishUnescape(_ value: String) -> String {
var result = ""
var iterator = value.makeIterator()
while let character = iterator.next() {
if character == "\\", let next = iterator.next() {
if next == "n" {
result.append("\n")
} else if next == "\\" {
result.append("\\")
} else {
result.append("\\")
result.append(next)
}
} else {
result.append(character)
}
}
return result
}
}