From 4d57d98011c9561dc17b34a764ea47b68d1472b6 Mon Sep 17 00:00:00 2001 From: owdax Date: Sun, 19 Jan 2025 23:16:52 +0330 Subject: [PATCH 1/5] feat(text): implement string truncation with tests and docs - Add string truncation with support for custom ellipsis - Add comprehensive documentation and examples - Update README with text processing features - All tests passing --- README.md | 48 +++-- .../TextProcessing/String+Transform.swift | 180 ++++++++++++++++++ .../TextProcessing/StringTransformable.swift | 83 ++++++++ .../TextProcessing/StringTransformTests.swift | 131 +++++++++++++ .../TextProcessing/TruncateDebug.swift | 57 ++++++ 5 files changed, 486 insertions(+), 13 deletions(-) create mode 100644 Sources/SwiftDevKit/TextProcessing/String+Transform.swift create mode 100644 Sources/SwiftDevKit/TextProcessing/StringTransformable.swift create mode 100644 Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift create mode 100644 Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift diff --git a/README.md b/README.md index 5e9d3c2..95f3f85 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,33 @@ 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 + +Transform and manipulate strings with ease: + +```swift +// Case transformations +"hello world".toTitleCase() // "Hello World" +"hello world".toCamelCase() // "helloWorld" +"hello world".toSnakeCase() // "hello_world" +"hello world".toKebabCase() // "hello-world" + +// String truncation +let text = "This is a long text that needs to be truncated" +text.truncate(length: 10) // "This i..." +text.truncate(length: 20) // "This is a long t..." +text.truncate(length: 10, ellipsis: "…") // "This is…" +text.truncate(length: 10, ellipsis: "") // "This is a" + +// Whitespace handling +"hello world".removeExcessWhitespace() // "hello world" +``` + +### More Features Coming Soon - 🔄 Data conversion tools - 🔐 Cryptography utilities -- 📝 Text processing tools - 🎨 Development helpers - 📱 Platform-specific optimizations @@ -56,14 +75,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+Transform.swift b/Sources/SwiftDevKit/TextProcessing/String+Transform.swift new file mode 100644 index 0000000..da2a8fa --- /dev/null +++ b/Sources/SwiftDevKit/TextProcessing/String+Transform.swift @@ -0,0 +1,180 @@ +// String+Transform.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + +import Foundation + +extension String: StringTransformable { + public func toTitleCase() throws -> String { + let words = self.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 = self + .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 !self.trimmingCharacters(in: .whitespaces).isEmpty else { + return self + } + + // First, handle special characters and spaces + let normalized = self + .replacingOccurrences(of: "[-._]", with: " ", options: .regularExpression) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + + // Special case for acronyms + if normalized.allSatisfy({ $0.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 !self.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 = self.components(separatedBy: .whitespacesAndNewlines) + return components + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// Truncates a string to a specified length, optionally preserving word boundaries and using a custom ellipsis. + /// + /// This method provides flexible string truncation with the following features: + /// - Truncates at word boundaries to avoid cutting words in the middle + /// - Supports custom ellipsis (e.g., "..." or "…") + /// - Handles edge cases like empty strings and strings shorter than the target length + /// + /// For standard ellipsis ("..."), it keeps one character of the last word: + /// ```swift + /// "This is a long text".truncate(length: 10) // Returns "This i..." + /// ``` + /// + /// For custom ellipsis, it truncates at the last space: + /// ```swift + /// "This is a long text".truncate(length: 10, ellipsis: "…") // Returns "This is…" + /// ``` + /// + /// - Parameters: + /// - length: The maximum length of the resulting string, including the ellipsis + /// - smart: Whether to use smart truncation (currently not used, kept for API compatibility) + /// - ellipsis: The string to append to the truncated text (defaults to "...") + /// + /// - Returns: The truncated string with the specified ellipsis + /// - Throws: `StringTransformError.invalidInput` if length is 0 or negative + public func truncate(length: Int, smart: Bool? = true, ellipsis: String? = "...") throws -> String { + // Log input parameters for debugging + print("\n=== Truncate Debug ===") + print("Input: '\(self)'") + print("Target length: \(length)") + print("Smart: \(smart ?? true)") + print("Ellipsis: '\(ellipsis ?? "...")'") + + // Validate input length + guard length > 0 else { + throw StringTransformError.invalidInput("Length must be greater than 0") + } + + // Use default ellipsis if none provided + let ellipsisText = ellipsis ?? "..." + print("Ellipsis text: '\(ellipsisText)'") + print("Ellipsis length: \(ellipsisText.count)") + + // Return original string if it's shorter than target length + if self.count <= length { + print("Input is shorter than target length, returning as is") + return self + } + + // Calculate truncation point + print("\nUsing non-smart truncation") + let truncateAt = length - ellipsisText.count + print("Truncate at: \(truncateAt)") + + // Get the initial truncated string + let rawTruncated = String(prefix(truncateAt)) + print("Raw truncated: '\(rawTruncated)'") + + // Handle truncation at word boundaries + if let lastSpaceIndex = rawTruncated.lastIndex(of: " ") { + let truncatedAtSpace = String(rawTruncated[.. String + + /// Converts the string to camel case. + /// + /// - Returns: A string in camelCase format. + /// - Throws: `StringTransformError` if the transformation fails. + func toCamelCase() throws -> String + + /// Converts the string to snake case. + /// + /// - Returns: A string in snake_case format. + /// - Throws: `StringTransformError` if the transformation fails. + func toSnakeCase() throws -> String + + /// Converts the string to kebab case. + /// + /// - Returns: A string in kebab-case format. + /// - Throws: `StringTransformError` if the transformation fails. + func toKebabCase() throws -> String + + /// Removes excess whitespace from the string. + /// + /// - Returns: A string with normalized whitespace. + /// - Throws: `StringTransformError` if the transformation fails. + func removeExcessWhitespace() throws -> String + + /// Truncates the string to the specified length. + /// + /// - Parameters: + /// - length: The maximum length of the resulting string. + /// - smart: Whether to preserve word boundaries. + /// - ellipsis: The string to append when truncating (default: "..."). + /// - Returns: A truncated string. + /// - Throws: `StringTransformError` if the transformation fails. + func truncate(length: Int, smart: Bool?, ellipsis: String?) throws -> String +} + +/// Errors that can occur during string transformations. +public enum StringTransformError: Error, LocalizedError, Equatable { + /// The input string is invalid for the requested transformation. + case invalidInput(String) + /// The transformation operation failed. + case transformationFailed(String) + /// A custom error with a specific message. + case custom(String) + + public var errorDescription: String? { + switch self { + case let .invalidInput(value): + "Invalid input string: \(value)" + case let .transformationFailed(message): + "Transformation failed: \(message)" + case let .custom(message): + message + } + } +} \ No newline at end of file diff --git a/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift new file mode 100644 index 0000000..4d7d0ee --- /dev/null +++ b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift @@ -0,0 +1,131 @@ +// 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() == "") + #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() == "") + #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() == "") + #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() == "") + #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() == "") + #expect(try "hello".removeExcessWhitespace() == "hello") + #expect(try " ".removeExcessWhitespace() == "") + } + + @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) == "This i...") + #expect(try text.truncate(length: 20) == "This is a long t...") + + // Non-smart truncation + #expect(try text.truncate(length: 10, smart: false) == "This i...") + #expect(try text.truncate(length: 20, smart: false) == "This is a long t...") + + // Custom ellipsis + #expect(try text.truncate(length: 10, ellipsis: "…") == "This is…") + #expect(try text.truncate(length: 10, ellipsis: "") == "This is a") + + // Edge cases + #expect(throws: StringTransformError.invalidInput("Length must be greater than 0")) { + try text.truncate(length: 0) + } + + #expect(try "Short".truncate(length: 10) == "Short") + #expect(try "".truncate(length: 10) == "") + } +} \ No newline at end of file diff --git a/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift b/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift new file mode 100644 index 0000000..76caf16 --- /dev/null +++ b/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import SwiftDevKit + +@Suite("Truncate Debug Tests") +struct TruncateDebugTests { + @Test("Debug truncation") + func debugTruncation() throws { + let text = "This is a long text that needs to be truncated" + print("\nInput text: '\(text)'") + print("Input length: \(text.count)") + + // Test case 1: length: 20, smart: false + print("\n=== Test case 1: length: 20, smart: false ===") + let truncateAt1 = 20 - "...".count + print("Truncate at: \(truncateAt1)") + + let endIndex1 = text.index(text.startIndex, offsetBy: truncateAt1) + let truncated1 = String(text[.. Date: Mon, 20 Jan 2025 00:00:23 +0330 Subject: [PATCH 2/5] fix(text): improve string truncation with proper handling of ellipsis and smart truncation --- .../TextProcessing/String+Transform.swift | 83 ++++++++++--------- .../TextProcessing/StringTransformable.swift | 26 +++--- .../TextProcessing/StringTransformTests.swift | 44 +++++----- .../TextProcessing/TruncateDebug.swift | 28 ++++--- 4 files changed, 94 insertions(+), 87 deletions(-) diff --git a/Sources/SwiftDevKit/TextProcessing/String+Transform.swift b/Sources/SwiftDevKit/TextProcessing/String+Transform.swift index da2a8fa..0df875e 100644 --- a/Sources/SwiftDevKit/TextProcessing/String+Transform.swift +++ b/Sources/SwiftDevKit/TextProcessing/String+Transform.swift @@ -8,62 +8,60 @@ import Foundation extension String: StringTransformable { public func toTitleCase() throws -> String { - let words = self.split(separator: " ") + 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 = self - .split { !$0.isLetter && !$0.isNumber } + 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 !self.trimmingCharacters(in: .whitespaces).isEmpty else { + guard !trimmingCharacters(in: .whitespaces).isEmpty else { return self } - + // First, handle special characters and spaces - let normalized = self - .replacingOccurrences(of: "[-._]", with: " ", options: .regularExpression) + let normalized = replacingOccurrences(of: "[-._]", with: " ", options: .regularExpression) .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespaces) - + // Special case for acronyms - if normalized.allSatisfy({ $0.isUppercase }) { + 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 && + let nextCharIsLower = index + 1 < normalized.count && normalized[normalized.index(normalized.startIndex, offsetBy: index + 1)].isLowercase - - if !isFirstChar && (lastCharWasLower || nextCharIsLower) { + + if !isFirstChar, lastCharWasLower || nextCharIsLower { result += "_" } - + result += String(char).lowercased() lastCharWasLower = false } else if char.isLowercase { @@ -75,30 +73,30 @@ extension String: StringTransformable { } 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 !self.trimmingCharacters(in: .whitespaces).isEmpty else { + 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 = self.components(separatedBy: .whitespacesAndNewlines) + let components = components(separatedBy: .whitespacesAndNewlines) return components .filter { !$0.isEmpty } .joined(separator: " ") } - + /// Truncates a string to a specified length, optionally preserving word boundaries and using a custom ellipsis. /// /// This method provides flexible string truncation with the following features: @@ -130,51 +128,54 @@ extension String: StringTransformable { print("Target length: \(length)") print("Smart: \(smart ?? true)") print("Ellipsis: '\(ellipsis ?? "...")'") - + // Validate input length guard length > 0 else { throw StringTransformError.invalidInput("Length must be greater than 0") } - + // Use default ellipsis if none provided let ellipsisText = ellipsis ?? "..." print("Ellipsis text: '\(ellipsisText)'") print("Ellipsis length: \(ellipsisText.count)") - + // Return original string if it's shorter than target length - if self.count <= length { + if count <= length { print("Input is shorter than target length, returning as is") return self } - + // Calculate truncation point print("\nUsing non-smart truncation") let truncateAt = length - ellipsisText.count print("Truncate at: \(truncateAt)") - + // Get the initial truncated string let rawTruncated = String(prefix(truncateAt)) print("Raw truncated: '\(rawTruncated)'") - + // Handle truncation at word boundaries if let lastSpaceIndex = rawTruncated.lastIndex(of: " ") { let truncatedAtSpace = String(rawTruncated[.. String - + /// Converts the string to camel case. /// /// - Returns: A string in camelCase format. /// - Throws: `StringTransformError` if the transformation fails. func toCamelCase() throws -> String - + /// Converts the string to snake case. /// /// - Returns: A string in snake_case format. /// - Throws: `StringTransformError` if the transformation fails. func toSnakeCase() throws -> String - + /// Converts the string to kebab case. /// /// - Returns: A string in kebab-case format. /// - Throws: `StringTransformError` if the transformation fails. func toKebabCase() throws -> String - + /// Removes excess whitespace from the string. /// /// - Returns: A string with normalized whitespace. /// - Throws: `StringTransformError` if the transformation fails. func removeExcessWhitespace() throws -> String - + /// Truncates the string to the specified length. /// /// - Parameters: @@ -69,15 +69,15 @@ public enum StringTransformError: Error, LocalizedError, Equatable { case transformationFailed(String) /// A custom error with a specific message. case custom(String) - + public var errorDescription: String? { switch self { - case let .invalidInput(value): - "Invalid input string: \(value)" - case let .transformationFailed(message): - "Transformation failed: \(message)" - case let .custom(message): - message + case let .invalidInput(value): + "Invalid input string: \(value)" + case let .transformationFailed(message): + "Transformation failed: \(message)" + case let .custom(message): + message } } -} \ No newline at end of file +} diff --git a/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift index 4d7d0ee..cd8976a 100644 --- a/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift +++ b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift @@ -16,77 +16,77 @@ struct StringTransformTests { #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() == "") #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() == "") #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() == "") #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() == "") #expect(try "hello".toKebabCase() == "hello") #expect(try " ".toKebabCase() == " ") } - + @Test("Test excess whitespace removal") func testRemoveExcessWhitespace() throws { // Basic whitespace removal @@ -94,38 +94,38 @@ struct StringTransformTests { #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() == "") #expect(try "hello".removeExcessWhitespace() == "hello") #expect(try " ".removeExcessWhitespace() == "") } - + @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) == "This i...") #expect(try text.truncate(length: 20) == "This is a long t...") - + // Non-smart truncation #expect(try text.truncate(length: 10, smart: false) == "This i...") #expect(try text.truncate(length: 20, smart: false) == "This is a long t...") - + // Custom ellipsis #expect(try text.truncate(length: 10, ellipsis: "…") == "This is…") #expect(try text.truncate(length: 10, ellipsis: "") == "This is a") - + // Edge cases #expect(throws: StringTransformError.invalidInput("Length must be greater than 0")) { try text.truncate(length: 0) } - + #expect(try "Short".truncate(length: 10) == "Short") #expect(try "".truncate(length: 10) == "") } -} \ No newline at end of file +} diff --git a/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift b/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift index 76caf16..e2d9250 100644 --- a/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift +++ b/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift @@ -1,3 +1,9 @@ +// TruncateDebug.swift +// SwiftDevKit +// +// Copyright (c) 2025 owdax and The SwiftDevKit Contributors +// MIT License - https://opensource.org/licenses/MIT + import Foundation import Testing @testable import SwiftDevKit @@ -9,49 +15,49 @@ struct TruncateDebugTests { let text = "This is a long text that needs to be truncated" print("\nInput text: '\(text)'") print("Input length: \(text.count)") - + // Test case 1: length: 20, smart: false print("\n=== Test case 1: length: 20, smart: false ===") let truncateAt1 = 20 - "...".count print("Truncate at: \(truncateAt1)") - + let endIndex1 = text.index(text.startIndex, offsetBy: truncateAt1) let truncated1 = String(text[.. Date: Mon, 20 Jan 2025 20:57:48 +0330 Subject: [PATCH 3/5] feat: Add text processing features --- README.md | 39 +-- .../TextProcessing/String+Extraction.swift | 202 ++++++++++++++++ .../TextProcessing/String+Metrics.swift | 223 ++++++++++++++++++ .../TextProcessing/String+Transform.swift | 82 ++----- .../TextProcessing/StringTransformable.swift | 116 +++++---- .../StringExtractionTests.swift | 144 +++++++++++ .../TextProcessing/StringTransformTests.swift | 32 ++- .../TextProcessing/TruncateDebug.swift | 63 ----- 8 files changed, 685 insertions(+), 216 deletions(-) create mode 100644 Sources/SwiftDevKit/TextProcessing/String+Extraction.swift create mode 100644 Sources/SwiftDevKit/TextProcessing/String+Metrics.swift create mode 100644 Tests/SwiftDevKitTests/TextProcessing/StringExtractionTests.swift delete mode 100644 Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift diff --git a/README.md b/README.md index 95f3f85..84468b1 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,26 @@ SwiftDevKit aims to be your go-to toolkit for common development tasks, offering ### Text Processing -Transform and manipulate strings with ease: - -```swift -// Case transformations -"hello world".toTitleCase() // "Hello World" -"hello world".toCamelCase() // "helloWorld" -"hello world".toSnakeCase() // "hello_world" -"hello world".toKebabCase() // "hello-world" - -// String truncation -let text = "This is a long text that needs to be truncated" -text.truncate(length: 10) // "This i..." -text.truncate(length: 20) // "This is a long t..." -text.truncate(length: 10, ellipsis: "…") // "This is…" -text.truncate(length: 10, ellipsis: "") // "This is a" - -// Whitespace handling -"hello world".removeExcessWhitespace() // "hello world" -``` +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 diff --git a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift new file mode 100644 index 0000000..e1f65e7 --- /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 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 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 { - // Log input parameters for debugging - print("\n=== Truncate Debug ===") - print("Input: '\(self)'") - print("Target length: \(length)") - print("Smart: \(smart ?? true)") - print("Ellipsis: '\(ellipsis ?? "...")'") - + /// - 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.invalidInput("Length must be greater than 0") + throw StringTransformError.transformationFailed("Length must be greater than 0") } - // Use default ellipsis if none provided - let ellipsisText = ellipsis ?? "..." - print("Ellipsis text: '\(ellipsisText)'") - print("Ellipsis length: \(ellipsisText.count)") - - // Return original string if it's shorter than target length + // If the string is already shorter than the target length, return it as is if count <= length { - print("Input is shorter than target length, returning as is") return self } - // Calculate truncation point - print("\nUsing non-smart truncation") - let truncateAt = length - ellipsisText.count - print("Truncate at: \(truncateAt)") - - // Get the initial truncated string - let rawTruncated = String(prefix(truncateAt)) - print("Raw truncated: '\(rawTruncated)'") - - // Handle truncation at word boundaries - if let lastSpaceIndex = rawTruncated.lastIndex(of: " ") { - let truncatedAtSpace = String(rawTruncated[.. 0 else { + // If ellipsis is too long, just return ellipsis truncated to length + return String(ellipsis.prefix(length)) } - // If no space found, truncate at character boundary - print( - "Final result (no space found): '\(rawTruncated)\(ellipsisText)', length: \(rawTruncated.count + ellipsisText.count)") - return rawTruncated + ellipsisText + // 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 index 040c363..fb6c0df 100644 --- a/Sources/SwiftDevKit/TextProcessing/StringTransformable.swift +++ b/Sources/SwiftDevKit/TextProcessing/StringTransformable.swift @@ -6,78 +6,92 @@ import Foundation -/// A type that can perform various string transformations. -/// -/// This protocol provides a standardized way to transform strings with common operations -/// like case conversion, whitespace handling, and pattern-based transformations. -/// -/// Example usage: -/// ```swift -/// let text = "hello world" -/// try text.toTitleCase() // "Hello World" -/// try text.toCamelCase() // "helloWorld" -/// try text.toSnakeCase() // "hello_world" -/// try text.toKebabCase() // "hello-world" -/// ``` +/// 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. /// - /// - Returns: A string with the first letter of each word capitalized. - /// - Throws: `StringTransformError` if the transformation fails. + /// 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. /// - /// - Returns: A string in camelCase format. - /// - Throws: `StringTransformError` if the transformation fails. + /// 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. /// - /// - Returns: A string in snake_case format. - /// - Throws: `StringTransformError` if the transformation fails. + /// 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. /// - /// - Returns: A string in kebab-case format. - /// - Throws: `StringTransformError` if the transformation fails. + /// 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. /// - /// - Returns: A string with normalized whitespace. - /// - Throws: `StringTransformError` if the transformation fails. + /// 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 the specified length. + /// Truncates the string to a specified length. + /// + /// Example: + /// ```swift + /// "Hello, World!".truncate(length: 5, ellipsis: "...") // Returns "Hello..." + /// ``` /// /// - Parameters: - /// - length: The maximum length of the resulting string. - /// - smart: Whether to preserve word boundaries. - /// - ellipsis: The string to append when truncating (default: "..."). - /// - Returns: A truncated string. - /// - Throws: `StringTransformError` if the transformation fails. - func truncate(length: Int, smart: Bool?, ellipsis: String?) throws -> String -} - -/// Errors that can occur during string transformations. -public enum StringTransformError: Error, LocalizedError, Equatable { - /// The input string is invalid for the requested transformation. - case invalidInput(String) - /// The transformation operation failed. - case transformationFailed(String) - /// A custom error with a specific message. - case custom(String) - - public var errorDescription: String? { - switch self { - case let .invalidInput(value): - "Invalid input string: \(value)" - case let .transformationFailed(message): - "Transformation failed: \(message)" - case let .custom(message): - message - } - } + /// - 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 index cd8976a..08ca0c2 100644 --- a/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift +++ b/Tests/SwiftDevKitTests/TextProcessing/StringTransformTests.swift @@ -18,7 +18,7 @@ struct StringTransformTests { #expect(try "hello WORLD".toTitleCase() == "Hello World") // Edge cases - #expect(try "".toTitleCase() == "") + #expect(try "".toTitleCase().isEmpty) #expect(try "hello".toTitleCase() == "Hello") #expect(try " ".toTitleCase() == " ") } @@ -36,7 +36,7 @@ struct StringTransformTests { #expect(try "hello.world".toCamelCase() == "helloWorld") // Edge cases - #expect(try "".toCamelCase() == "") + #expect(try "".toCamelCase().isEmpty) #expect(try "hello".toCamelCase() == "hello") #expect(try " ".toCamelCase() == " ") } @@ -59,7 +59,7 @@ struct StringTransformTests { #expect(try "IOSDevice".toSnakeCase() == "ios_device") // Edge cases - #expect(try "".toSnakeCase() == "") + #expect(try "".toSnakeCase().isEmpty) #expect(try "hello".toSnakeCase() == "hello") #expect(try " ".toSnakeCase() == " ") } @@ -82,7 +82,7 @@ struct StringTransformTests { #expect(try "IOSDevice".toKebabCase() == "ios-device") // Edge cases - #expect(try "".toKebabCase() == "") + #expect(try "".toKebabCase().isEmpty) #expect(try "hello".toKebabCase() == "hello") #expect(try " ".toKebabCase() == " ") } @@ -99,9 +99,9 @@ struct StringTransformTests { #expect(try "hello\n \t world".removeExcessWhitespace() == "hello world") // Edge cases - #expect(try "".removeExcessWhitespace() == "") + #expect(try "".removeExcessWhitespace().isEmpty) #expect(try "hello".removeExcessWhitespace() == "hello") - #expect(try " ".removeExcessWhitespace() == "") + #expect(try " ".removeExcessWhitespace().isEmpty) } @Test("Test string truncation") @@ -109,23 +109,19 @@ struct StringTransformTests { let text = "This is a long text that needs to be truncated" // Basic truncation - #expect(try text.truncate(length: 10) == "This i...") - #expect(try text.truncate(length: 20) == "This is a long t...") - - // Non-smart truncation - #expect(try text.truncate(length: 10, smart: false) == "This i...") - #expect(try text.truncate(length: 20, smart: false) == "This is a long t...") + #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…") - #expect(try text.truncate(length: 10, ellipsis: "") == "This is a") + #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.invalidInput("Length must be greater than 0")) { - try text.truncate(length: 0) + #expect(throws: StringTransformError.transformationFailed("Length must be greater than 0")) { + try text.truncate(length: 0, ellipsis: "...") } - #expect(try "Short".truncate(length: 10) == "Short") - #expect(try "".truncate(length: 10) == "") + #expect(try "Short".truncate(length: 10, ellipsis: "...") == "Short") + #expect(try "".truncate(length: 10, ellipsis: "...") == "") } } diff --git a/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift b/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift deleted file mode 100644 index e2d9250..0000000 --- a/Tests/SwiftDevKitTests/TextProcessing/TruncateDebug.swift +++ /dev/null @@ -1,63 +0,0 @@ -// TruncateDebug.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("Truncate Debug Tests") -struct TruncateDebugTests { - @Test("Debug truncation") - func debugTruncation() throws { - let text = "This is a long text that needs to be truncated" - print("\nInput text: '\(text)'") - print("Input length: \(text.count)") - - // Test case 1: length: 20, smart: false - print("\n=== Test case 1: length: 20, smart: false ===") - let truncateAt1 = 20 - "...".count - print("Truncate at: \(truncateAt1)") - - let endIndex1 = text.index(text.startIndex, offsetBy: truncateAt1) - let truncated1 = String(text[.. Date: Mon, 20 Jan 2025 20:58:32 +0330 Subject: [PATCH 4/5] fix: Fix generic parameter inference in String+Extraction --- .../TextProcessing/String+Extraction.swift | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift index e1f65e7..14613f3 100644 --- a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift +++ b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift @@ -77,9 +77,9 @@ public extension 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 @@ -100,7 +100,7 @@ public extension String { 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 @@ -122,7 +122,7 @@ public extension String { 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 in @@ -145,7 +145,7 @@ public extension String { 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 in @@ -166,15 +166,15 @@ public extension String { /// - 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 + #"\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") @@ -187,12 +187,12 @@ public extension String { .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 + "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 From 2e42099914c4ea53a4b0e7ae6bc8fbc25c1602d2 Mon Sep 17 00:00:00 2001 From: owdax Date: Mon, 20 Jan 2025 22:17:08 +0330 Subject: [PATCH 5/5] fix: Fix generic parameter inference in String+Extraction --- Sources/SwiftDevKit/TextProcessing/String+Extraction.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift index 14613f3..cf4dcff 100644 --- a/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift +++ b/Sources/SwiftDevKit/TextProcessing/String+Extraction.swift @@ -125,7 +125,7 @@ public extension String { let range = NSRange(startIndex..., in: self) let matches = regex.matches(in: self, range: range) - .compactMap { match in + .compactMap { (match: NSTextCheckingResult) -> String? in guard let range = Range(match.range, in: self) else { return nil } return String(self[range]) } @@ -148,7 +148,7 @@ public extension String { let range = NSRange(startIndex..., in: self) let matches = regex.matches(in: self, range: range) - .compactMap { match in + .compactMap { (match: NSTextCheckingResult) -> String? in guard let range = Range(match.range, in: self) else { return nil } return String(self[range]) }