diff --git a/README.md b/README.md index 5e9d3c2..84468b1 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,34 @@ Think of it as your Swiss Army knife for Swift development - all the tools you n SwiftDevKit aims to be your go-to toolkit for common development tasks, offering a wide range of functionalities from data conversion to code generation. Our goal is to provide a comprehensive, well-tested, and professionally crafted SDK that brings the power of web-based developer tools natively to Swift. -## Planned Features +## Features -- 🛠 Comprehensive utility functions -- 📦 Modular architecture -- 💻 Native Swift implementation +### Text Processing + +SwiftDevKit provides powerful text processing tools for common string manipulation tasks: + +#### String Transformations +- Case transformations (toTitleCase, toCamelCase, toSnakeCase, toKebabCase) +- Smart string truncation with customizable length and ellipsis +- Whitespace handling (removeExcessWhitespace) + +#### String Distance Calculations +- Levenshtein distance for measuring edit distance between strings +- Jaro-Winkler distance for name matching and fuzzy string comparison +- Hamming distance for strings of equal length + +#### String Extraction +- Extract numbers (with optional negative number handling) +- Extract words with minimum length filtering +- Extract sentences from text +- Extract URLs with custom scheme filtering +- Extract email addresses +- Extract hashtags and mentions (social media style) +- Extract dates from text + +### More Features Coming Soon - 🔄 Data conversion tools - 🔐 Cryptography utilities -- 📝 Text processing tools - 🎨 Development helpers - 📱 Platform-specific optimizations @@ -56,14 +76,17 @@ pod 'SwiftDevKit' ## Development Roadmap 1. 🏗 Core Infrastructure (In Progress) - - Setting up development tools - - Establishing CI/CD pipeline - - Code quality tools integration - -2. 🧰 Core Features (Planned) - - Data Conversion utilities - - Text Processing tools - - Development helpers + - Setting up development tools ✅ + - Establishing CI/CD pipeline ✅ + - Code quality tools integration ✅ + +2. 🧰 Core Features (In Progress) + - Text Processing tools ✅ + - Case transformations + - String truncation + - Whitespace handling + - Data Conversion utilities (Planned) + - Development helpers (Planned) 3. 🔒 Advanced Features (Planned) - Cryptography utilities diff --git a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift new file mode 100644 index 0000000..cf4dcff --- /dev/null +++ b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift @@ -0,0 +1,202 @@ +// String+Extraction.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +public extension String { + /// Extracts all numbers from the string. + /// + /// Example: + /// ```swift + /// let text = "The price is $19.99 and quantity is 42" + /// let numbers = text.extractNumbers() // ["19.99", "42"] + /// ``` + /// + /// - Parameter includeNegative: Whether to include negative numbers (default: true) + /// - Returns: An array of strings containing the extracted numbers + func extractNumbers(includeNegative: Bool = true) -> [String] { + let pattern = includeNegative ? + #"-?\d+\.?\d*"# : // Match numbers with optional negative sign + #"(? [String] { + components(separatedBy: .punctuationCharacters) + .joined() + .components(separatedBy: .whitespaces) + .filter { $0.count >= minLength && !$0.isEmpty } + } + + /// Extracts all sentences from the string. + /// + /// Example: + /// ```swift + /// let text = "Hello! How are you? I'm doing great." + /// let sentences = text.extractSentences() // ["Hello!", "How are you?", "I'm doing great."] + /// ``` + /// + /// - Returns: An array of strings containing the extracted sentences + func extractSentences() -> [String] { + components(separatedBy: CharacterSet(charactersIn: ".!?")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + /// Extracts all URLs from the string. + /// + /// Example: + /// ```swift + /// let text = "Visit https://www.example.com or http://test.com" + /// let urls = text.extractURLs() // ["https://www.example.com", "http://test.com"] + /// ``` + /// + /// - Parameter schemes: Array of URL schemes to match (default: ["http", "https"]) + /// - Returns: An array of strings containing the extracted URLs + func extractURLs(schemes: [String] = ["http", "https"]) -> [String] { + let pattern = schemes + .map { #"\b\#($0)://[^\s<>\"]+[\w]"# } + .joined(separator: "|") + + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range) + .compactMap { match in + guard let range = Range(match.range, in: self) else { return nil } + return String(self[range]) + } + } + + /// Extracts all email addresses from the string. + /// + /// Example: + /// ```swift + /// let text = "Contact us at info@example.com or support@test.com" + /// let emails = text.extractEmails() // ["info@example.com", "support@test.com"] + /// ``` + /// + /// - Returns: An array of strings containing the extracted email addresses + func extractEmails() -> [String] { + let pattern = #"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,64}"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + let range = NSRange(startIndex..., in: self) + return regex.matches(in: self, range: range) + .compactMap { match in + guard let range = Range(match.range, in: self) else { return nil } + return String(self[range]) + } + } + + /// Extracts all hashtags from the string. + /// + /// Example: + /// ```swift + /// let text = "Check out #SwiftDev and #iOS15 features!" + /// let hashtags = text.extractHashtags() // ["#SwiftDev", "#iOS15"] + /// ``` + /// + /// - Parameter includeHash: Whether to include the # symbol (default: true) + /// - Returns: An array of strings containing the extracted hashtags + func extractHashtags(includeHash: Bool = true) -> [String] { + let pattern = #"#[a-zA-Z][a-zA-Z0-9_]*"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + let range = NSRange(startIndex..., in: self) + let matches = regex.matches(in: self, range: range) + .compactMap { (match: NSTextCheckingResult) -> String? in + guard let range = Range(match.range, in: self) else { return nil } + return String(self[range]) + } + return matches.map { (str: String) -> String in includeHash ? str : String(str.dropFirst()) } + } + + /// Extracts all mentions (@username) from the string. + /// + /// Example: + /// ```swift + /// let text = "Thanks @john and @jane_doe!" + /// let mentions = text.extractMentions() // ["@john", "@jane_doe"] + /// ``` + /// + /// - Parameter includeAt: Whether to include the @ symbol (default: true) + /// - Returns: An array of strings containing the extracted mentions + func extractMentions(includeAt: Bool = true) -> [String] { + let pattern = #"@[a-zA-Z][a-zA-Z0-9_]*"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + let range = NSRange(startIndex..., in: self) + let matches = regex.matches(in: self, range: range) + .compactMap { (match: NSTextCheckingResult) -> String? in + guard let range = Range(match.range, in: self) else { return nil } + return String(self[range]) + } + return matches.map { (str: String) -> String in includeAt ? str : String(str.dropFirst()) } + } + + /// Extracts all dates from the string. + /// + /// Example: + /// ```swift + /// let text = "Meeting on 2024-01-16 and party on 01/20/2024" + /// let dates = text.extractDates() // ["2024-01-16", "01/20/2024"] + /// ``` + /// + /// - Returns: An array of strings containing the extracted valid dates + func extractDates() -> [String] { + let patterns = [ + #"\d{4}-\d{2}-\d{2}"#, // YYYY-MM-DD + #"\d{2}/\d{2}/\d{4}"#, // MM/DD/YYYY + #"\d{2}\.\d{2}\.\d{4}"#, // DD.MM.YYYY + #"[A-Za-z]+ \d{1,2}, \d{4}"#, // Month DD, YYYY + ] + + let combinedPattern = patterns.joined(separator: "|") + guard let regex = try? NSRegularExpression(pattern: combinedPattern) else { return [] } + + let range = NSRange(startIndex..., in: self) + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone(identifier: "UTC") + + return regex.matches(in: self, range: range) + .compactMap { match in + guard let range = Range(match.range, in: self) else { return nil } + return String(self[range]) + } + .filter { dateString in + // Try different date formats + let formats = [ + "yyyy-MM-dd", // YYYY-MM-DD + "MM/dd/yyyy", // MM/DD/YYYY + "dd.MM.yyyy", // DD.MM.YYYY + "MMMM d, yyyy", // Month DD, YYYY + ] + + return formats.contains { format in + dateFormatter.dateFormat = format + return dateFormatter.date(from: dateString) != nil + } + } + } +} diff --git a/Sources/SwiftDevKit/TextProcessing/String+Metrics.swift b/Sources/SwiftDevKit/TextProcessing/String+Metrics.swift new file mode 100644 index 0000000..1d8b64a --- /dev/null +++ b/Sources/SwiftDevKit/TextProcessing/String+Metrics.swift @@ -0,0 +1,223 @@ +// String+Metrics.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +public extension String { + /// Calculates the Levenshtein distance between this string and another string. + /// + /// The Levenshtein distance is the minimum number of single-character edits (insertions, deletions, or + /// substitutions) + /// required to change one string into another. + /// + /// Example: + /// ```swift + /// let distance = "kitten".levenshteinDistance(to: "sitting") // Returns 3 + /// ``` + /// + /// - Parameter other: The string to compare against + /// - Returns: The Levenshtein distance between the two strings + func levenshteinDistance(to other: String) -> Int { + let str1 = Array(self) + let str2 = Array(other) + let str1Length = str1.count + let str2Length = str2.count + + // Handle empty strings + if str1Length == 0 { return str2Length } + if str2Length == 0 { return str1Length } + + // Create matrix of size (str1Length+1)x(str2Length+1) + var matrix = Array(repeating: Array(repeating: 0, count: str2Length + 1), count: str1Length + 1) + + // Initialize first row and column + for i in 0...str1Length { + matrix[i][0] = i + } + for j in 0...str2Length { + matrix[0][j] = j + } + + // Fill in the rest of the matrix + for i in 1...str1Length { + for j in 1...str2Length { + if str1[i - 1] == str2[j - 1] { + matrix[i][j] = matrix[i - 1][j - 1] // No operation needed + } else { + matrix[i][j] = Swift.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + 1 // substitution + ) + } + } + } + + return matrix[str1Length][str2Length] + } + + /// Calculates the Jaro-Winkler distance between this string and another string. + /// + /// The Jaro-Winkler distance is a measure of similarity between two strings, with a higher score + /// indicating greater similarity (1.0 means the strings are identical, 0.0 means completely different). + /// It's particularly suited for short strings like names. + /// + /// Example: + /// ```swift + /// let similarity = "martha".jaroWinklerDistance(to: "marhta") // Returns ~0.961 + /// ``` + /// + /// - Parameters: + /// - other: The string to compare against + /// - scalingFactor: The scaling factor for how much the prefix affects the score (default: 0.1) + /// - Returns: A value between 0.0 and 1.0, where 1.0 means the strings are identical + func jaroWinklerDistance(to other: String, scalingFactor: Double = 0.1) -> Double { + let str1 = Array(self) + let str2 = Array(other) + let len1 = str1.count + let len2 = str2.count + + // If either string is empty, return 0 + if len1 == 0 || len2 == 0 { + return 0.0 + } + + // Maximum distance between two characters to be considered matching + let matchDistance = (Swift.max(len1, len2) / 2) - 1 + + // Arrays to keep track of matched characters + var matches1 = [Bool](repeating: false, count: len1) + var matches2 = [Bool](repeating: false, count: len2) + + // Count matching characters + var matchingChars = 0 + for i in 0.. 0 else { + return 0.0 + } + + // Count transpositions + var transpositions = 0 + var j = 0 + for i in 0.. Int { + guard count == other.count else { + throw StringMetricsError.unequalLength( + "Hamming distance requires strings of equal length: \(count) != \(other.count)") + } + + var distance = 0 + for i in 0.. String { + let words = split(separator: " ") + guard !words.isEmpty else { + return self + } + + return words + .map { $0.prefix(1).uppercased() + $0.dropFirst().lowercased() } + .joined(separator: " ") + } + + public func toCamelCase() throws -> String { + let words = split { !$0.isLetter && !$0.isNumber } + .enumerated() + .map { index, word -> String in + let lowercased = word.lowercased() + return index == 0 ? lowercased : lowercased.prefix(1).uppercased() + lowercased.dropFirst() + } + + guard !words.isEmpty else { + return self + } + + return words.joined() + } + + public func toSnakeCase() throws -> String { + // Handle empty strings and whitespace-only strings + guard !trimmingCharacters(in: .whitespaces).isEmpty else { + return self + } + + // First, handle special characters and spaces + let normalized = replacingOccurrences(of: "[-._]", with: " ", options: .regularExpression) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + + // Special case for acronyms + if normalized.allSatisfy(\.isUppercase) { + return normalized.lowercased().map { String($0) }.joined(separator: "_") + } + + var result = "" + var lastCharWasLower = false + var isFirstChar = true + + for (index, char) in normalized.enumerated() { + if char.isUppercase { + let nextCharIsLower = index + 1 < normalized.count && + normalized[normalized.index(normalized.startIndex, offsetBy: index + 1)].isLowercase + + if !isFirstChar, lastCharWasLower || nextCharIsLower { + result += "_" + } + + result += String(char).lowercased() + lastCharWasLower = false + } else if char.isLowercase { + result += String(char) + lastCharWasLower = true + } else if char.isWhitespace { + result += "_" + lastCharWasLower = false + } + isFirstChar = false + } + + // Clean up any double underscores and trim + return result + .replacingOccurrences(of: "_+", with: "_", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + } + + public func toKebabCase() throws -> String { + // Handle empty strings and whitespace-only strings + guard !trimmingCharacters(in: .whitespaces).isEmpty else { + return self + } + + // Convert to snake case first, then replace underscores with hyphens + return try toSnakeCase().replacingOccurrences(of: "_", with: "-") + } + + public func removeExcessWhitespace() throws -> String { + let components = components(separatedBy: .whitespacesAndNewlines) + return components + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// Truncates the string to a specified length. + /// + /// Example: + /// ```swift + /// "Hello, World!".truncate(length: 5, ellipsis: "...") // Returns "Hello..." + /// ``` + /// + /// - Parameters: + /// - length: The maximum length of the truncated string (including ellipsis) + /// - ellipsis: The string to append to truncated text + /// - Returns: The truncated string + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + public func truncate(length: Int, ellipsis: String) throws -> String { + // Validate input length + guard length > 0 else { + throw StringTransformError.transformationFailed("Length must be greater than 0") + } + + // If the string is already shorter than the target length, return it as is + if count <= length { + return self + } + + // Calculate where to truncate, accounting for ellipsis length + let truncateAt = length - ellipsis.count + guard truncateAt > 0 else { + // If ellipsis is too long, just return ellipsis truncated to length + return String(ellipsis.prefix(length)) + } + + // Simple truncation at exact character position + return String(prefix(truncateAt)) + ellipsis + } +} diff --git a/Sources/SwiftDevKit/TextProcessing/StringTransformable.swift b/Sources/SwiftDevKit/TextProcessing/StringTransformable.swift new file mode 100644 index 0000000..fb6c0df --- /dev/null +++ b/Sources/SwiftDevKit/TextProcessing/StringTransformable.swift @@ -0,0 +1,97 @@ +// StringTransformable.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +/// Errors that can occur during string transformations. +public enum StringTransformError: LocalizedError, Equatable { + /// Thrown when a string transformation operation fails. + case transformationFailed(String) + + /// Thrown when strings have unequal length for operations requiring equal lengths. + case unequalLength(String) + + public var errorDescription: String? { + switch self { + case let .transformationFailed(message): + "String transformation failed: \(message)" + case let .unequalLength(message): + "String length mismatch: \(message)" + } + } +} + +/// A protocol defining string transformation operations. +public protocol StringTransformable { + /// Converts the string to title case. + /// + /// Example: + /// ```swift + /// "hello world".toTitleCase() // Returns "Hello World" + /// ``` + /// + /// - Returns: The string in title case + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func toTitleCase() throws -> String + + /// Converts the string to camel case. + /// + /// Example: + /// ```swift + /// "hello world".toCamelCase() // Returns "helloWorld" + /// ``` + /// + /// - Returns: The string in camel case + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func toCamelCase() throws -> String + + /// Converts the string to snake case. + /// + /// Example: + /// ```swift + /// "hello world".toSnakeCase() // Returns "hello_world" + /// ``` + /// + /// - Returns: The string in snake case + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func toSnakeCase() throws -> String + + /// Converts the string to kebab case. + /// + /// Example: + /// ```swift + /// "hello world".toKebabCase() // Returns "hello-world" + /// ``` + /// + /// - Returns: The string in kebab case + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func toKebabCase() throws -> String + + /// Removes excess whitespace from the string. + /// + /// Example: + /// ```swift + /// " hello world ".removeExcessWhitespace() // Returns "hello world" + /// ``` + /// + /// - Returns: The string with excess whitespace removed + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func removeExcessWhitespace() throws -> String + + /// Truncates the string to a specified length. + /// + /// Example: + /// ```swift + /// "Hello, World!".truncate(length: 5, ellipsis: "...") // Returns "Hello..." + /// ``` + /// + /// - Parameters: + /// - length: The maximum length of the truncated string (including ellipsis) + /// - ellipsis: The string to append to truncated text + /// - Returns: The truncated string + /// - Throws: `StringTransformError.transformationFailed` if the transformation fails + func truncate(length: Int, ellipsis: String) throws -> String +} diff --git a/Tests/SwiftDevKitTests/TextProcessing/StringExtractionTests.swift b/Tests/SwiftDevKitTests/TextProcessing/StringExtractionTests.swift new file mode 100644 index 0000000..3be6850 --- /dev/null +++ b/Tests/SwiftDevKitTests/TextProcessing/StringExtractionTests.swift @@ -0,0 +1,144 @@ +// StringExtractionTests.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation +import Testing +@testable import SwiftDevKit + +@Suite("String Extraction Tests") +struct StringExtractionTests { + @Test("Test number extraction") + func testNumberExtraction() throws { + let text = "The price is $19.99 and quantity is -42" + + // With negative numbers + let numbers = text.extractNumbers() + #expect(numbers.count == 2) + #expect(numbers.contains("19.99")) + #expect(numbers.contains("-42")) + + // Without negative numbers + let positiveOnly = text.extractNumbers(includeNegative: false) + #expect(positiveOnly.count == 1) + #expect(positiveOnly.contains("19.99")) + } + + @Test("Test word extraction") + func testWordExtraction() throws { + let text = "Hello, World! How are you?" + + // Default minimum length + let words = text.extractWords() + #expect(words.count == 5) + #expect(words == ["Hello", "World", "How", "are", "you"]) + + // Custom minimum length + let longWords = text.extractWords(minLength: 4) + #expect(longWords.count == 2) + #expect(longWords == ["Hello", "World"]) + } + + @Test("Test sentence extraction") + func testSentenceExtraction() throws { + let text = "Hello! How are you? I'm doing great." + + let sentences = text.extractSentences() + #expect(sentences.count == 3) + #expect(sentences == ["Hello", "How are you", "I'm doing great"]) + + // Empty string + #expect("".extractSentences().isEmpty) + + // Single sentence + #expect("Just one sentence.".extractSentences() == ["Just one sentence"]) + } + + @Test("Test URL extraction") + func testURLExtraction() throws { + let text = "Visit https://www.example.com or http://test.com or ftp://files.com" + + // Default schemes (http, https) + let urls = text.extractURLs() + #expect(urls.count == 2) + #expect(urls.contains("https://www.example.com")) + #expect(urls.contains("http://test.com")) + + // Custom schemes + let allUrls = text.extractURLs(schemes: ["http", "https", "ftp"]) + #expect(allUrls.count == 3) + #expect(allUrls.contains("ftp://files.com")) + } + + @Test("Test email extraction") + func testEmailExtraction() throws { + let text = "Contact us at info@example.com or support@test.com" + + let emails = text.extractEmails() + #expect(emails.count == 2) + #expect(emails.contains("info@example.com")) + #expect(emails.contains("support@test.com")) + + // Invalid emails + let invalidText = "Invalid emails: @invalid.com and user@.com" + #expect(invalidText.extractEmails().isEmpty) + } + + @Test("Test hashtag extraction") + func testHashtagExtraction() throws { + let text = "Check out #SwiftDev and #iOS15 features!" + + // With hash symbol + let hashtags = text.extractHashtags() + #expect(hashtags.count == 2) + #expect(hashtags.contains("#SwiftDev")) + #expect(hashtags.contains("#iOS15")) + + // Without hash symbol + let tags = text.extractHashtags(includeHash: false) + #expect(tags.count == 2) + #expect(tags.contains("SwiftDev")) + #expect(tags.contains("iOS15")) + } + + @Test("Test mention extraction") + func testMentionExtraction() throws { + let text = "Thanks @john and @jane_doe!" + + // With @ symbol + let mentions = text.extractMentions() + #expect(mentions.count == 2) + #expect(mentions.contains("@john")) + #expect(mentions.contains("@jane_doe")) + + // Without @ symbol + let usernames = text.extractMentions(includeAt: false) + #expect(usernames.count == 2) + #expect(usernames.contains("john")) + #expect(usernames.contains("jane_doe")) + } + + @Test("Test date extraction") + func testDateExtraction() throws { + let text = """ + Meeting on 2024-01-16 + Party on 01/20/2024 + Event on 25.12.2024 + Conference on January 30, 2024 + """ + + let dates = text.extractDates() + #expect(dates.count == 4) + #expect(dates.contains("2024-01-16")) + #expect(dates.contains("01/20/2024")) + #expect(dates.contains("25.12.2024")) + #expect(dates.contains("January 30, 2024")) + + // Invalid dates + let invalidText = "Invalid dates: 2024-13-45 and 00/00/0000" + let invalidDates = invalidText.extractDates() + #expect(invalidDates.isEmpty) + } +} diff --git a/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift new file mode 100644 index 0000000..08ca0c2 --- /dev/null +++ b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift @@ -0,0 +1,127 @@ +// StringTransformTests.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation +import Testing +@testable import SwiftDevKit + +@Suite("String Transform Tests") +struct StringTransformTests { + @Test("Test title case transformation") + func testTitleCase() throws { + // Basic title case + #expect(try "hello world".toTitleCase() == "Hello World") + #expect(try "HELLO WORLD".toTitleCase() == "Hello World") + #expect(try "hello WORLD".toTitleCase() == "Hello World") + + // Edge cases + #expect(try "".toTitleCase().isEmpty) + #expect(try "hello".toTitleCase() == "Hello") + #expect(try " ".toTitleCase() == " ") + } + + @Test("Test camel case transformation") + func testCamelCase() throws { + // Basic camel case + #expect(try "hello world".toCamelCase() == "helloWorld") + #expect(try "Hello World".toCamelCase() == "helloWorld") + #expect(try "HELLO WORLD".toCamelCase() == "helloWorld") + + // With special characters + #expect(try "hello-world".toCamelCase() == "helloWorld") + #expect(try "hello_world".toCamelCase() == "helloWorld") + #expect(try "hello.world".toCamelCase() == "helloWorld") + + // Edge cases + #expect(try "".toCamelCase().isEmpty) + #expect(try "hello".toCamelCase() == "hello") + #expect(try " ".toCamelCase() == " ") + } + + @Test("Test snake case transformation") + func testSnakeCase() throws { + // Basic snake case + #expect(try "hello world".toSnakeCase() == "hello_world") + #expect(try "helloWorld".toSnakeCase() == "hello_world") + #expect(try "HelloWorld".toSnakeCase() == "hello_world") + + // With special characters + #expect(try "hello-world".toSnakeCase() == "hello_world") + #expect(try "hello.world".toSnakeCase() == "hello_world") + #expect(try "hello world".toSnakeCase() == "hello_world") + + // Complex cases + #expect(try "ThisIsALongVariableName".toSnakeCase() == "this_is_a_long_variable_name") + #expect(try "ABC".toSnakeCase() == "a_b_c") + #expect(try "IOSDevice".toSnakeCase() == "ios_device") + + // Edge cases + #expect(try "".toSnakeCase().isEmpty) + #expect(try "hello".toSnakeCase() == "hello") + #expect(try " ".toSnakeCase() == " ") + } + + @Test("Test kebab case transformation") + func testKebabCase() throws { + // Basic kebab case + #expect(try "hello world".toKebabCase() == "hello-world") + #expect(try "helloWorld".toKebabCase() == "hello-world") + #expect(try "HelloWorld".toKebabCase() == "hello-world") + + // With special characters + #expect(try "hello_world".toKebabCase() == "hello-world") + #expect(try "hello.world".toKebabCase() == "hello-world") + #expect(try "hello world".toKebabCase() == "hello-world") + + // Complex cases + #expect(try "ThisIsALongVariableName".toKebabCase() == "this-is-a-long-variable-name") + #expect(try "ABC".toKebabCase() == "a-b-c") + #expect(try "IOSDevice".toKebabCase() == "ios-device") + + // Edge cases + #expect(try "".toKebabCase().isEmpty) + #expect(try "hello".toKebabCase() == "hello") + #expect(try " ".toKebabCase() == " ") + } + + @Test("Test excess whitespace removal") + func testRemoveExcessWhitespace() throws { + // Basic whitespace removal + #expect(try "hello world".removeExcessWhitespace() == "hello world") + #expect(try " hello world ".removeExcessWhitespace() == "hello world") + #expect(try "hello\nworld".removeExcessWhitespace() == "hello world") + #expect(try "hello\tworld".removeExcessWhitespace() == "hello world") + + // Multiple types of whitespace + #expect(try "hello\n \t world".removeExcessWhitespace() == "hello world") + + // Edge cases + #expect(try "".removeExcessWhitespace().isEmpty) + #expect(try "hello".removeExcessWhitespace() == "hello") + #expect(try " ".removeExcessWhitespace().isEmpty) + } + + @Test("Test string truncation") + func testTruncate() throws { + let text = "This is a long text that needs to be truncated" + + // Basic truncation + #expect(try text.truncate(length: 10, ellipsis: "...") == "This is...") + #expect(try text.truncate(length: 20, ellipsis: "...") == "This is a long te...") + + // Custom ellipsis + #expect(try text.truncate(length: 10, ellipsis: "…") == "This is a…") + #expect(try text.truncate(length: 10, ellipsis: "") == "This is a ") + + // Edge cases + #expect(throws: StringTransformError.transformationFailed("Length must be greater than 0")) { + try text.truncate(length: 0, ellipsis: "...") + } + + #expect(try "Short".truncate(length: 10, ellipsis: "...") == "Short") + #expect(try "".truncate(length: 10, ellipsis: "...") == "") + } +}