import Foundation /// How a station's address should read underneath its name. /// /// The official Fuel Finder `address` field is not a street address on its own. /// It normally opens with the station's own name — 14% of stations start with /// the exact name, and many more repeat it in the first segment ("CHIDSWELL /// SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell") — and /// 13% end with the postcode the row appends again. With the name already in the /// row's header (and in the detail sheet's "Name" row), both repetitions read as /// the name stated twice. /// /// The rules are deliberately conservative: a leading segment carrying a house /// number is a real street address and is left alone, and a partial-word match /// ("Jet" against "JETSON FILLING STATION") never cuts into a word. enum StationAddressLine { /// Words that carry no identity, so a leading segment made only of these /// plus words taken from the name still counts as the name. private static let filler: Set = [ "service", "services", "station", "stations", "filling", "petrol", "garage", "motors", "motor", "centre", "center", "store", "superstore", "ltd", "limited", "trading", "the", "and", "of", "at", ] /// The line to display: any leading repeat of the name removed, and the /// postcode written exactly once (whatever the address already carries). static func display(address: String, postcode: String, name: String) -> String { let postcodeText = postcode.trimmingCharacters(in: .whitespacesAndNewlines) var text = collapsingSpaces(address) if text.isEmpty { return postcodeText } text = removingLeadingName(from: text, name: name) text = removingTrailingPostcode(from: text, postcode: postcodeText) text = text.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-")) if text.isEmpty { return postcodeText } if postcodeText.isEmpty { return text } return "\(text), \(postcodeText)" } // MARK: - Internals private static func collapsingSpaces(_ text: String) -> String { text.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" }) .joined(separator: " ") .trimmingCharacters(in: .whitespaces) } /// Letters and digits only, lowercased — so punctuation and spacing cannot /// make two spellings of the same name look different. private static func key(_ text: String) -> String { text.lowercased().filter { $0.isLetter || $0.isNumber } } private static func removingLeadingName(from text: String, name: String) -> String { let nameKey = key(name) guard !nameKey.isEmpty else { return text } if key(text).hasPrefix(nameKey), endsAtWordBoundary(text, characters: nameKey.count) { return droppingFirst(text, characters: nameKey.count) } return removingLeadingNameSegment(from: text, nameKey: nameKey) } /// True when the first `count` letters/digits of `text` are followed by /// something that is not part of a word — a partial-word match must never /// cut a station out of the middle of a word. private static func endsAtWordBoundary(_ text: String, characters count: Int) -> Bool { var seen = 0 var index = text.startIndex while index < text.endIndex, seen < count { let character = text[index] if character.isLetter || character.isNumber { seen += 1 } index = text.index(after: index) } guard index < text.endIndex else { return true } let next = text[index] return !(next.isLetter || next.isNumber) } /// Drops the first `count` letters/digits of `text` and any separator after /// them, leaving the rest of the original spelling untouched. private static func droppingFirst(_ text: String, characters count: Int) -> String { var seen = 0 var index = text.startIndex while index < text.endIndex, seen < count { let character = text[index] if character.isLetter || character.isNumber { seen += 1 } index = text.index(after: index) } return String(text[index...]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-")) } /// Drops a trailing repeat of the postcode. private static func removingTrailingPostcode(from text: String, postcode: String) -> String { let postcodeKey = key(postcode) guard !postcodeKey.isEmpty else { return text } let textKey = key(text) guard textKey.hasSuffix(postcodeKey), textKey.count > postcodeKey.count else { return text } var count = postcodeKey.count var index = text.endIndex while index > text.startIndex, count > 0 { index = text.index(before: index) let character = text[index] if character.isLetter || character.isNumber { count -= 1 } } return String(text[text.startIndex.. String { guard let comma = text.firstIndex(of: ",") else { return text } let segment = String(text[text.startIndex..= 3 && !filler.contains($0) } guard !significant.isEmpty, significant.allSatisfy({ nameKey.contains($0) }) else { return text } return String(text[text.index(after: comma)...]) .trimmingCharacters(in: CharacterSet(charactersIn: " ,;-")) } }