// // String+Extensions.swift // oAI // // String utility extensions // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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: @ 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[..