Initial commit
This commit is contained in:
78
oAI/Utilities/Extensions/Color+Extensions.swift
Normal file
78
oAI/Utilities/Extensions/Color+Extensions.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Color+Extensions.swift
|
||||
// oAI
|
||||
//
|
||||
// Color scheme matching Python TUI dark theme
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
// MARK: - oAI Color Palette (Matching Python TUI)
|
||||
|
||||
static let oaiBackground = Color(hex: "#1e1e1e") // Main background
|
||||
static let oaiSurface = Color(hex: "#2d2d2d") // Cards, surfaces
|
||||
static let oaiPrimary = Color(hex: "#cccccc") // Primary text
|
||||
static let oaiSecondary = Color(hex: "#888888") // Secondary text
|
||||
static let oaiAccent = Color(hex: "#0a7aca") // Blue accent (assistant)
|
||||
static let oaiSuccess = Color(hex: "#90ee90") // Green (user messages)
|
||||
static let oaiError = Color(hex: "#ff6b6b") // Red (errors)
|
||||
static let oaiWarning = Color(hex: "#ffaa00") // Orange (warnings)
|
||||
static let oaiBorder = Color(hex: "#555555") // Borders, dividers
|
||||
|
||||
// MARK: - Message Role Colors
|
||||
|
||||
static func messageColor(for role: MessageRole) -> Color {
|
||||
switch role {
|
||||
case .user: return .oaiSuccess
|
||||
case .assistant: return .oaiAccent
|
||||
case .system: return .oaiSecondary
|
||||
}
|
||||
}
|
||||
|
||||
static func messageBackground(for role: MessageRole) -> Color {
|
||||
switch role {
|
||||
case .user: return .oaiSurface
|
||||
case .assistant: return .oaiBackground
|
||||
case .system: return Color(hex: "#2a2a2a")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Colors
|
||||
|
||||
static func providerColor(_ provider: Settings.Provider) -> Color {
|
||||
switch provider {
|
||||
case .openrouter: return Color(hex: "#7c3aed") // Purple
|
||||
case .anthropic: return Color(hex: "#d4895a") // Orange
|
||||
case .openai: return Color(hex: "#10a37f") // Green
|
||||
case .ollama: return Color(hex: "#ffffff") // White
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex Initializer
|
||||
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
}
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
87
oAI/Utilities/Extensions/String+Extensions.swift
Normal file
87
oAI/Utilities/Extensions/String+Extensions.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// String+Extensions.swift
|
||||
// oAI
|
||||
//
|
||||
// String utility extensions
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
// MARK: - Command Parsing
|
||||
|
||||
var isSlashCommand: Bool {
|
||||
hasPrefix("/")
|
||||
}
|
||||
|
||||
func parseCommand() -> (command: String, args: [String])? {
|
||||
guard isSlashCommand else { return nil }
|
||||
|
||||
let parts = self.split(separator: " ", omittingEmptySubsequences: true)
|
||||
.map(String.init)
|
||||
|
||||
guard let command = parts.first else { return nil }
|
||||
let args = Array(parts.dropFirst())
|
||||
|
||||
return (command, args)
|
||||
}
|
||||
|
||||
// MARK: - File Attachment Parsing
|
||||
|
||||
func parseFileAttachments() -> (cleanText: String, filePaths: [String]) {
|
||||
var cleanText = self
|
||||
var filePaths: [String] = []
|
||||
|
||||
// Pattern 1: @<filepath>
|
||||
let anglePattern = #"@<([^>]+)>"#
|
||||
if let regex = try? NSRegularExpression(pattern: anglePattern) {
|
||||
let matches = regex.matches(in: self, range: NSRange(self.startIndex..., in: self))
|
||||
for match in matches.reversed() {
|
||||
if let range = Range(match.range(at: 1), in: self) {
|
||||
let path = String(self[range])
|
||||
filePaths.insert(path, at: 0)
|
||||
}
|
||||
if let fullRange = Range(match.range, in: self) {
|
||||
cleanText.removeSubrange(fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: @filepath (starting with /, ~, ., or drive letter)
|
||||
let directPattern = #"@([~/.][\S]+|[A-Za-z]:[\\\/][\S]+)"#
|
||||
if let regex = try? NSRegularExpression(pattern: directPattern) {
|
||||
let matches = regex.matches(in: cleanText, range: NSRange(cleanText.startIndex..., in: cleanText))
|
||||
for match in matches.reversed() {
|
||||
if let range = Range(match.range(at: 1), in: cleanText) {
|
||||
let path = String(cleanText[range])
|
||||
if !filePaths.contains(path) {
|
||||
filePaths.insert(path, at: 0)
|
||||
}
|
||||
}
|
||||
if let fullRange = Range(match.range, in: cleanText) {
|
||||
cleanText.removeSubrange(fullRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (cleanText.trimmingCharacters(in: .whitespaces), filePaths)
|
||||
}
|
||||
|
||||
// MARK: - Token Estimation
|
||||
|
||||
func estimateTokens() -> Int {
|
||||
// Rough estimation: ~4 characters per token
|
||||
// This is approximate; Phase 2 will use proper tokenizer
|
||||
return max(1, count / 4)
|
||||
}
|
||||
|
||||
// MARK: - Truncation
|
||||
|
||||
func truncated(to length: Int, trailing: String = "...") -> String {
|
||||
if count <= length {
|
||||
return self
|
||||
}
|
||||
let endIndex = index(startIndex, offsetBy: length - trailing.count)
|
||||
return String(self[..<endIndex]) + trailing
|
||||
}
|
||||
}
|
||||
81
oAI/Utilities/Extensions/View+Extensions.swift
Normal file
81
oAI/Utilities/Extensions/View+Extensions.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// View+Extensions.swift
|
||||
// oAI
|
||||
//
|
||||
// SwiftUI view helpers and modifiers
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
// MARK: - Conditional Modifiers
|
||||
|
||||
@ViewBuilder
|
||||
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func ifLet<Value, Transform: View>(_ value: Value?, transform: (Self, Value) -> Transform) -> some View {
|
||||
if let value = value {
|
||||
transform(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Helpers
|
||||
|
||||
#if os(macOS)
|
||||
func onCommandReturn(perform action: @escaping () -> Void) -> some View {
|
||||
self
|
||||
// Note: onKeyPress modifiers don't work in command-line Swift build
|
||||
// This will be implemented when running in actual Xcode project
|
||||
// For now, using keyboard shortcuts in toolbar instead
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Common Styling
|
||||
|
||||
func oaiCardStyle() -> some View {
|
||||
self
|
||||
.background(Color.oaiSurface)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
func oaiButton() -> some View {
|
||||
self
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.oaiSurface)
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
func oaiTextField() -> some View {
|
||||
self
|
||||
.padding(8)
|
||||
.background(Color.oaiBackground)
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Frame Helpers
|
||||
|
||||
extension View {
|
||||
func frame(square size: CGFloat) -> some View {
|
||||
self.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
136
oAI/Utilities/Logging.swift
Normal file
136
oAI/Utilities/Logging.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// Logging.swift
|
||||
// oAI
|
||||
//
|
||||
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Log Level
|
||||
|
||||
enum LogLevel: Int, Comparable, CaseIterable, Sendable {
|
||||
case debug = 0
|
||||
case info = 1
|
||||
case warning = 2
|
||||
case error = 3
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .debug: return "DEBUG"
|
||||
case .info: return "INFO"
|
||||
case .warning: return "WARN"
|
||||
case .error: return "ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .debug: return "Debug"
|
||||
case .info: return "Info"
|
||||
case .warning: return "Warning"
|
||||
case .error: return "Error"
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
|
||||
lhs.rawValue < rhs.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Logger
|
||||
|
||||
final class FileLogger: @unchecked Sendable {
|
||||
static let shared = FileLogger()
|
||||
|
||||
private let fileHandle: FileHandle?
|
||||
private let queue = DispatchQueue(label: "com.oai.filelogger")
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Current minimum log level (read from UserDefaults for thread safety)
|
||||
var minimumLevel: LogLevel {
|
||||
get {
|
||||
let raw = UserDefaults.standard.integer(forKey: "logLevel")
|
||||
return LogLevel(rawValue: raw) ?? .info
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue.rawValue, forKey: "logLevel")
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
let logsDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Logs")
|
||||
let logFile = logsDir.appendingPathComponent("oAI.log")
|
||||
|
||||
// Ensure file exists
|
||||
if !FileManager.default.fileExists(atPath: logFile.path) {
|
||||
FileManager.default.createFile(atPath: logFile.path, contents: nil)
|
||||
}
|
||||
|
||||
fileHandle = try? FileHandle(forWritingTo: logFile)
|
||||
fileHandle?.seekToEndOfFile()
|
||||
}
|
||||
|
||||
func write(_ level: LogLevel, category: String, message: String) {
|
||||
guard level >= minimumLevel else { return }
|
||||
queue.async { [weak self] in
|
||||
guard let self, let fh = self.fileHandle else { return }
|
||||
let timestamp = self.dateFormatter.string(from: Date())
|
||||
let line = "[\(timestamp)] [\(level.label)] [\(category)] \(message)\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
fh.write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
fileHandle?.closeFile()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Logger (wraps os.Logger + file)
|
||||
|
||||
struct AppLogger {
|
||||
let osLogger: Logger
|
||||
let category: String
|
||||
|
||||
func debug(_ message: String) {
|
||||
FileLogger.shared.write(.debug, category: category, message: message)
|
||||
osLogger.debug("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
FileLogger.shared.write(.info, category: category, message: message)
|
||||
osLogger.info("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func warning(_ message: String) {
|
||||
FileLogger.shared.write(.warning, category: category, message: message)
|
||||
osLogger.warning("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
FileLogger.shared.write(.error, category: category, message: message)
|
||||
osLogger.error("\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log Namespace
|
||||
|
||||
enum Log {
|
||||
private static let subsystem = "com.oai.oAI"
|
||||
|
||||
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
|
||||
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
|
||||
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
|
||||
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
|
||||
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
|
||||
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
|
||||
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
|
||||
}
|
||||
294
oAI/Utilities/SyntaxHighlighter.swift
Normal file
294
oAI/Utilities/SyntaxHighlighter.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
//
|
||||
// SyntaxHighlighter.swift
|
||||
// oAI
|
||||
//
|
||||
// Keyword-based syntax highlighting using AttributedString
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SyntaxHighlighter {
|
||||
|
||||
// MARK: - Token Colors (dark theme)
|
||||
|
||||
static let keywordColor = Color(hex: "#569cd6") // Blue
|
||||
static let stringColor = Color(hex: "#ce9178") // Orange
|
||||
static let commentColor = Color(hex: "#6a9955") // Green
|
||||
static let numberColor = Color(hex: "#b5cea8") // Light green
|
||||
static let typeColor = Color(hex: "#4ec9b0") // Teal
|
||||
static let functionColor = Color(hex: "#dcdcaa") // Yellow
|
||||
static let defaultColor = Color(hex: "#d4d4d4") // Light gray
|
||||
static let punctuationColor = Color(hex: "#808080") // Gray
|
||||
|
||||
// MARK: - Language Keywords
|
||||
|
||||
private static let keywords: [String: Set<String>] = [
|
||||
"swift": ["import", "func", "class", "struct", "enum", "protocol", "extension",
|
||||
"var", "let", "if", "else", "guard", "switch", "case", "default",
|
||||
"for", "while", "repeat", "return", "break", "continue", "throw",
|
||||
"throws", "try", "catch", "do", "async", "await", "in", "where",
|
||||
"self", "Self", "super", "init", "deinit", "nil", "true", "false",
|
||||
"public", "private", "internal", "fileprivate", "open", "static",
|
||||
"override", "mutating", "weak", "unowned", "lazy", "some", "any",
|
||||
"typealias", "associatedtype", "inout", "as", "is", "defer"],
|
||||
"python": ["import", "from", "def", "class", "if", "elif", "else", "for",
|
||||
"while", "return", "yield", "break", "continue", "pass", "raise",
|
||||
"try", "except", "finally", "with", "as", "lambda", "and", "or",
|
||||
"not", "in", "is", "True", "False", "None", "self", "async", "await",
|
||||
"global", "nonlocal", "del", "assert", "print"],
|
||||
"javascript": ["function", "const", "let", "var", "if", "else", "for", "while",
|
||||
"do", "switch", "case", "default", "return", "break", "continue",
|
||||
"throw", "try", "catch", "finally", "class", "extends", "new",
|
||||
"this", "super", "import", "export", "from", "async", "await",
|
||||
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
|
||||
"true", "false", "null", "undefined", "debugger"],
|
||||
"typescript": ["function", "const", "let", "var", "if", "else", "for", "while",
|
||||
"do", "switch", "case", "default", "return", "break", "continue",
|
||||
"throw", "try", "catch", "finally", "class", "extends", "new",
|
||||
"this", "super", "import", "export", "from", "async", "await",
|
||||
"yield", "of", "in", "typeof", "instanceof", "delete", "void",
|
||||
"true", "false", "null", "undefined", "type", "interface",
|
||||
"enum", "implements", "abstract", "readonly", "as", "keyof",
|
||||
"namespace", "declare", "module"],
|
||||
"go": ["package", "import", "func", "type", "struct", "interface", "var",
|
||||
"const", "if", "else", "for", "range", "switch", "case", "default",
|
||||
"return", "break", "continue", "go", "defer", "select", "chan",
|
||||
"map", "make", "new", "append", "len", "cap", "nil", "true", "false",
|
||||
"fallthrough", "goto"],
|
||||
"rust": ["fn", "let", "mut", "const", "if", "else", "match", "for", "while",
|
||||
"loop", "return", "break", "continue", "struct", "enum", "impl",
|
||||
"trait", "pub", "use", "mod", "crate", "self", "super", "as", "in",
|
||||
"ref", "move", "async", "await", "where", "type", "dyn", "unsafe",
|
||||
"extern", "true", "false", "Some", "None", "Ok", "Err"],
|
||||
"java": ["class", "interface", "enum", "extends", "implements", "import",
|
||||
"package", "public", "private", "protected", "static", "final",
|
||||
"abstract", "void", "int", "long", "double", "float", "boolean",
|
||||
"char", "byte", "short", "if", "else", "for", "while", "do",
|
||||
"switch", "case", "default", "return", "break", "continue",
|
||||
"throw", "throws", "try", "catch", "finally", "new", "this",
|
||||
"super", "null", "true", "false", "synchronized", "volatile"],
|
||||
"c": ["if", "else", "for", "while", "do", "switch", "case", "default",
|
||||
"return", "break", "continue", "goto", "typedef", "struct", "union",
|
||||
"enum", "const", "static", "extern", "volatile", "register", "auto",
|
||||
"void", "int", "long", "short", "char", "float", "double", "unsigned",
|
||||
"signed", "sizeof", "NULL", "include", "define", "ifdef", "ifndef",
|
||||
"endif", "pragma"],
|
||||
"cpp": ["if", "else", "for", "while", "do", "switch", "case", "default",
|
||||
"return", "break", "continue", "goto", "typedef", "struct", "union",
|
||||
"enum", "const", "static", "extern", "volatile", "class", "public",
|
||||
"private", "protected", "virtual", "override", "template", "typename",
|
||||
"namespace", "using", "new", "delete", "throw", "try", "catch",
|
||||
"nullptr", "true", "false", "auto", "constexpr", "inline",
|
||||
"void", "int", "long", "short", "char", "float", "double", "bool",
|
||||
"include", "define", "ifdef", "ifndef", "endif"],
|
||||
"sql": ["SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE",
|
||||
"SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX",
|
||||
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR",
|
||||
"NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "EXISTS", "AS",
|
||||
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION",
|
||||
"DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", "PRIMARY",
|
||||
"KEY", "FOREIGN", "REFERENCES", "CASCADE", "CONSTRAINT",
|
||||
"select", "from", "where", "insert", "into", "values", "update",
|
||||
"set", "delete", "create", "table", "alter", "drop", "index",
|
||||
"join", "left", "right", "inner", "outer", "on", "and", "or",
|
||||
"not", "null", "is", "in", "like", "between", "exists", "as",
|
||||
"order", "by", "group", "having", "limit", "offset", "union",
|
||||
"distinct", "primary", "key", "foreign", "references"],
|
||||
"shell": ["if", "then", "else", "elif", "fi", "for", "while", "do", "done",
|
||||
"case", "esac", "function", "return", "exit", "echo", "export",
|
||||
"local", "readonly", "shift", "set", "unset", "source", "eval",
|
||||
"exec", "cd", "pwd", "ls", "cp", "mv", "rm", "mkdir", "cat",
|
||||
"grep", "sed", "awk", "find", "xargs", "pipe", "true", "false",
|
||||
"in", "sudo", "chmod", "chown"],
|
||||
"html": ["html", "head", "body", "div", "span", "p", "a", "img", "ul", "ol",
|
||||
"li", "table", "tr", "td", "th", "form", "input", "button", "select",
|
||||
"option", "textarea", "script", "style", "link", "meta", "title",
|
||||
"header", "footer", "nav", "main", "section", "article", "aside",
|
||||
"class", "id", "href", "src", "alt", "type", "value", "name"],
|
||||
"css": ["color", "background", "margin", "padding", "border", "font",
|
||||
"display", "position", "width", "height", "top", "left", "right",
|
||||
"bottom", "flex", "grid", "align", "justify", "transform", "transition",
|
||||
"animation", "opacity", "overflow", "z-index", "important",
|
||||
"none", "block", "inline", "absolute", "relative", "fixed", "sticky"],
|
||||
"ruby": ["def", "end", "class", "module", "if", "elsif", "else", "unless",
|
||||
"while", "until", "for", "do", "begin", "rescue", "ensure", "raise",
|
||||
"return", "yield", "block_given?", "require", "include", "extend",
|
||||
"attr_accessor", "attr_reader", "attr_writer", "self", "super",
|
||||
"nil", "true", "false", "puts", "print", "lambda", "proc"],
|
||||
]
|
||||
|
||||
// MARK: - Comment Styles
|
||||
|
||||
private static let lineCommentPrefixes: [String: String] = [
|
||||
"swift": "//", "python": "#", "javascript": "//", "typescript": "//",
|
||||
"go": "//", "rust": "//", "java": "//", "c": "//", "cpp": "//",
|
||||
"shell": "#", "bash": "#", "ruby": "#", "yaml": "#", "toml": "#",
|
||||
]
|
||||
|
||||
// MARK: - Language Aliases
|
||||
|
||||
private static let languageAliases: [String: String] = [
|
||||
"js": "javascript", "ts": "typescript", "py": "python",
|
||||
"sh": "shell", "bash": "shell", "zsh": "shell",
|
||||
"c++": "cpp", "objective-c": "c", "objc": "c",
|
||||
"yml": "yaml", "md": "markdown", "rb": "ruby",
|
||||
"h": "c", "hpp": "cpp", "m": "c",
|
||||
]
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
static func highlight(code: String, language: String?) -> AttributedString {
|
||||
let lang = resolveLanguage(language)
|
||||
let langKeywords = keywords[lang] ?? Set()
|
||||
let commentPrefix = lineCommentPrefixes[lang]
|
||||
|
||||
var result = AttributedString()
|
||||
let lines = code.components(separatedBy: "\n")
|
||||
|
||||
for (lineIndex, line) in lines.enumerated() {
|
||||
let highlightedLine = highlightLine(line, keywords: langKeywords, commentPrefix: commentPrefix, language: lang)
|
||||
result.append(highlightedLine)
|
||||
if lineIndex < lines.count - 1 {
|
||||
result.append(AttributedString("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func resolveLanguage(_ lang: String?) -> String {
|
||||
guard let lang = lang?.lowercased().trimmingCharacters(in: .whitespaces), !lang.isEmpty else {
|
||||
return ""
|
||||
}
|
||||
return languageAliases[lang] ?? lang
|
||||
}
|
||||
|
||||
private static func highlightLine(_ line: String, keywords: Set<String>, commentPrefix: String?, language: String) -> AttributedString {
|
||||
// Check for line comments
|
||||
if let prefix = commentPrefix {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix(prefix) {
|
||||
var attr = AttributedString(line)
|
||||
attr.foregroundColor = commentColor
|
||||
return attr
|
||||
}
|
||||
}
|
||||
|
||||
// Tokenize and colorize
|
||||
var result = AttributedString()
|
||||
var i = line.startIndex
|
||||
|
||||
while i < line.endIndex {
|
||||
let c = line[i]
|
||||
|
||||
// String literals
|
||||
if c == "\"" || c == "'" || c == "`" {
|
||||
let (strAttr, newIndex) = consumeString(line, from: i, quote: c)
|
||||
result.append(strAttr)
|
||||
i = newIndex
|
||||
continue
|
||||
}
|
||||
|
||||
// Block comment start
|
||||
if c == "/" && line.index(after: i) < line.endIndex && line[line.index(after: i)] == "*" {
|
||||
// Consume rest of line as comment (simplified — no multi-line tracking)
|
||||
let rest = String(line[i...])
|
||||
var attr = AttributedString(rest)
|
||||
attr.foregroundColor = commentColor
|
||||
result.append(attr)
|
||||
return result
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if c.isNumber && (i == line.startIndex || !line[line.index(before: i)].isLetter) {
|
||||
let (numAttr, newIndex) = consumeNumber(line, from: i)
|
||||
result.append(numAttr)
|
||||
i = newIndex
|
||||
continue
|
||||
}
|
||||
|
||||
// Words (identifiers/keywords)
|
||||
if c.isLetter || c == "_" || c == "@" || c == "#" {
|
||||
let (wordAttr, newIndex) = consumeWord(line, from: i, keywords: keywords)
|
||||
result.append(wordAttr)
|
||||
i = newIndex
|
||||
continue
|
||||
}
|
||||
|
||||
// Punctuation/operators
|
||||
var charAttr = AttributedString(String(c))
|
||||
charAttr.foregroundColor = defaultColor
|
||||
result.append(charAttr)
|
||||
i = line.index(after: i)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func consumeString(_ line: String, from start: String.Index, quote: Character) -> (AttributedString, String.Index) {
|
||||
var i = line.index(after: start)
|
||||
var str = String(quote)
|
||||
|
||||
while i < line.endIndex {
|
||||
let c = line[i]
|
||||
str.append(c)
|
||||
if c == "\\" && line.index(after: i) < line.endIndex {
|
||||
// Escaped character
|
||||
i = line.index(after: i)
|
||||
str.append(line[i])
|
||||
i = line.index(after: i)
|
||||
continue
|
||||
}
|
||||
i = line.index(after: i)
|
||||
if c == quote {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var attr = AttributedString(str)
|
||||
attr.foregroundColor = stringColor
|
||||
return (attr, i)
|
||||
}
|
||||
|
||||
private static func consumeNumber(_ line: String, from start: String.Index) -> (AttributedString, String.Index) {
|
||||
var i = start
|
||||
var num = ""
|
||||
|
||||
while i < line.endIndex && (line[i].isHexDigit || line[i] == "." || line[i] == "x" || line[i] == "X" || line[i] == "_") {
|
||||
num.append(line[i])
|
||||
i = line.index(after: i)
|
||||
}
|
||||
|
||||
var attr = AttributedString(num)
|
||||
attr.foregroundColor = numberColor
|
||||
return (attr, i)
|
||||
}
|
||||
|
||||
private static func consumeWord(_ line: String, from start: String.Index, keywords: Set<String>) -> (AttributedString, String.Index) {
|
||||
var i = start
|
||||
var word = ""
|
||||
|
||||
while i < line.endIndex && (line[i].isLetter || line[i].isNumber || line[i] == "_" || line[i] == "@" || line[i] == "#") {
|
||||
word.append(line[i])
|
||||
i = line.index(after: i)
|
||||
}
|
||||
|
||||
var attr = AttributedString(word)
|
||||
|
||||
if keywords.contains(word) {
|
||||
attr.foregroundColor = keywordColor
|
||||
} else if word.first?.isUppercase == true && word.count > 1 {
|
||||
// Type-like identifier (capitalized)
|
||||
attr.foregroundColor = typeColor
|
||||
} else if i < line.endIndex && line[i] == "(" {
|
||||
// Function call
|
||||
attr.foregroundColor = functionColor
|
||||
} else {
|
||||
attr.foregroundColor = defaultColor
|
||||
}
|
||||
|
||||
return (attr, i)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user