|
| 1 | +// |
| 2 | +// TextProcessor.swift |
| 3 | +// Ignite |
| 4 | +// https://www.github.com/twostraws/Ignite |
| 5 | +// See LICENSE for license information. |
| 6 | +// |
| 7 | + |
| 8 | +import Foundation |
| 9 | + |
| 10 | +/// Text processing utilities for search indexing. |
| 11 | +public enum TextProcessor { |
| 12 | + /// Removes all HTML tags and entities, collapsing whitespace. |
| 13 | + public static func stripHTML(_ html: String) -> String { |
| 14 | + html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) |
| 15 | + .replacingOccurrences(of: "&[^;]+;", with: " ", options: .regularExpression) |
| 16 | + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| 17 | + .trimmingCharacters(in: .whitespacesAndNewlines) |
| 18 | + } |
| 19 | + |
| 20 | + /// Generates a plain-text excerpt of the specified length, breaking at a word boundary. |
| 21 | + public static func excerpt(from text: String, length: Int) -> String { |
| 22 | + guard text.count > length else { return text } |
| 23 | + let truncated = String(text.prefix(length)) |
| 24 | + if let lastSpace = truncated.lastIndex(of: " ") { |
| 25 | + return String(truncated[truncated.startIndex..<lastSpace]) + "..." |
| 26 | + } |
| 27 | + return truncated + "..." |
| 28 | + } |
| 29 | + |
| 30 | + /// Tokenizes text into individual searchable terms. |
| 31 | + public static func tokenize( |
| 32 | + _ text: String, |
| 33 | + language: SearchLanguage, |
| 34 | + stem: Bool, |
| 35 | + removeStopWords: Bool |
| 36 | + ) -> [String] { |
| 37 | + let words = text |
| 38 | + .lowercased() |
| 39 | + .components(separatedBy: .alphanumerics.inverted) |
| 40 | + .filter { !$0.isEmpty } |
| 41 | + |
| 42 | + var tokens = words |
| 43 | + |
| 44 | + if removeStopWords { |
| 45 | + let stopWords = language.stopWords |
| 46 | + tokens = tokens.filter { !stopWords.contains($0) } |
| 47 | + } |
| 48 | + |
| 49 | + if stem { |
| 50 | + tokens = tokens.map { stemWord($0, language: language) } |
| 51 | + } |
| 52 | + |
| 53 | + var seen = Set<String>() |
| 54 | + tokens = tokens.filter { seen.insert($0).inserted } |
| 55 | + |
| 56 | + return tokens |
| 57 | + } |
| 58 | + |
| 59 | + /// Applies basic Porter stemming rules for English. |
| 60 | + public static func stemWord(_ word: String, language: SearchLanguage) -> String { |
| 61 | + guard language == .english else { return word } |
| 62 | + guard word.count > 3 else { return word } |
| 63 | + |
| 64 | + var stem = word |
| 65 | + |
| 66 | + let suffixes: [(String, String)] = [ |
| 67 | + ("ousness", "ous"), ("iveness", "ive"), ("fulness", "ful"), |
| 68 | + ("ization", "ize"), ("ational", "ate"), |
| 69 | + ("tional", "tion"), ("biliti", "ble"), ("iviti", "ive"), |
| 70 | + ("aliti", "al"), ("ousli", "ous"), ("entli", "ent"), |
| 71 | + ("alism", "al"), ("ation", "ate"), ("ator", "ate"), |
| 72 | + ("anci", "ance"), ("enci", "ence"), ("izer", "ize"), |
| 73 | + ("alli", "al"), ("eli", "e") |
| 74 | + ] |
| 75 | + |
| 76 | + for (suffix, replacement) in suffixes { |
| 77 | + if stem.hasSuffix(suffix) { |
| 78 | + return String(stem.dropLast(suffix.count)) + replacement |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + let simpleSuffixes = ["ing", "tion", "ness", "ment", "able", "ible", |
| 83 | + "ful", "less", "ous", "ive", "ly", "ed", "er", "es", "s"] |
| 84 | + for suffix in simpleSuffixes { |
| 85 | + if stem.hasSuffix(suffix) && stem.count - suffix.count >= 3 { |
| 86 | + stem = String(stem.dropLast(suffix.count)) |
| 87 | + break |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + return stem |
| 92 | + } |
| 93 | +} |
0 commit comments