복잡한 문자열을 구조화된 데이터로 바꾸는 과정은 쉽지 않다.
String은 Collection이나 고차함수나, 인덱스 기반으로 할 수는 있겠지만, 썩 좋지 않다.
let transaction = "DEBIT 03/05/2022 Doug's Dugout Dogs $33.27"
let fragments = transaction.split(whereSeparator: \\.isWhitespace)
// ["DEBIT", "03/05/2022", "Doug\\'s", "Dugout", "Dogs", "$33.27"]
////
var slice = transaction[...]
// Extract a field, advancing `slice` to the start of the next field
func extractField() -> Substring {
let endIdx = {
var start = slice.startIndex
while true {
// Position of next whitespace (including tabs)
guard let spaceIdx = slice[start...].firstIndex(where: \\.isWhitespace) else {
return slice.endIndex
}
// Tab suffices
if slice[spaceIdx] == "\\t" {
return spaceIdx
}
// Otherwise check for a second whitespace character
let afterSpaceIdx = slice.index(after: spaceIdx)
if afterSpaceIdx == slice.endIndex || slice[afterSpaceIdx].isWhitespace {
return spaceIdx
}
// Skip over the single space and try again
start = afterSpaceIdx
}
}()
defer { slice = slice[endIdx...].drop(while: \\.isWhitespace) }
return slice[..<endIdx]
}
let kind = extractField()
let date = try Date(String(extractField()), strategy: Date.FormatStyle(date: .numeric))
let account = extractField()
let amount = try Decimal(String(extractField()), format: .currency(code: "USD"))
이런 문제를 해결하기 위해서 많은 언어에서는 정규표현식(regular expression, regex)를 쓴다.
리터럴 형태의 regex
let digits = /\\d+/
// digits: Regex<Substring>
런타임에 만들기
// Run-time construction
let runtimeString = #"\\d+"#
let digits = try Regex(runtimeString)
// digits: Regex<AnyRegexOutput>
builder 형태로 만들기
// Regex builders
let digits = OneOrMore(.digit)
// digits: Regex<Substring>
split에 적용하기
let transaction = "DEBIT 03/05/2022 Doug's Dugout Dogs $33.27"
let fragments = transaction.split(separator: /\\s{2,}|\\t/)
// ["DEBIT", "03/05/2022", "Doug's Dugout Dogs", "$33.27"]
// normalizing하기
let normalized = transaction.replacing(/\\s{2,}|\\t/, with: "\\t")
// DEBIT»03/05/2022»Doug's Dugout Dogs»$33.27
regex가 좋긴 한데, 쓰는 입장에서는 이해하기가 어렵다는 문제가 있다.
swift는 4가지 영역에서 regex를 발전시켰다
예시
// CREDIT 03/02/2022 Payroll from employer $200.23
// CREDIT 03/03/2022 Suspect A $2,000,000.00
// DEBIT 03/03/2022 Ted's Pet Rock Sanctuary $2,000,000.00
// DEBIT 03/05/2022 Doug's Dugout Dogs $33.27
import RegexBuilder
let fieldSeparator = /\\s{2,}|\\t/
let transactionMatcher = Regex {
/CREDIT|DEBIT/
fieldSeparator
One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt))
fieldSeparator
OneOrMore {
NegativeLookahead { fieldSeparator } // 해당 파서가 성공하면 자신을 감싸고 있는 파서를 멈춤
CharacterClass.any
}
fieldSeparator
One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US")))
}
값을 캡쳐하기.
let fieldSeparator = /\\s{2,}|\\t/
let transactionMatcher = Regex {
Capture { /CREDIT|DEBIT/ }
fieldSeparator
Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) }
fieldSeparator
Capture {
OneOrMore {
NegativeLookahead { fieldSeparator }
CharacterClass.any
}
}
fieldSeparator
Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) }
}
// transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>
// 첫번째 Substring은 input 중 regex가 매치된 영역을 뜻한다.
특정 데이터에 따라서 다른 데이터의 의미가 달라지는 경우가 있을 수 있는데…
private let ledger = """
KIND DATE INSTITUTION AMOUNT
----------------------------------------------------------------
CREDIT 03/01/2022 Payroll from employer $200.23
CREDIT 03/03/2022 Suspect A $2,000,000.00
DEBIT 03/03/2022 Ted's Pet Rock Sanctuary $2,000,000.00
DEBIT 03/05/2022 Doug's Dugout Dogs $33.27
DEBIT 06/03/2022 Oxford Comma Supply Ltd. £57.33
"""
// 달러일 때는 mm/dd/yyyy
// 파운드일때는 dd/mm/yyyy
이런 경우에는 sed와 비슷한 문법을 regex에 적용할 수 있다. → named capture
let regex = #/
(?<date> \\d{2} / \\d{2} / \\d{4})
(?<middle> \\P{currencySymbol}+)
(?<currency> \\p{currencySymbol})
/#
// Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
currency 결과를 보고 파싱 전략을 결정하기
let regex = #/
(?<date> \\d{2} / \\d{2} / \\d{4})
(?<middle> \\P{currencySymbol}+)
(?<currency> \\p{currencySymbol})
/#
// Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
func pickStrategy(_ currency: Substring) -> Date.ParseStrategy {
switch currency {
case "$": return .date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)
case "£": return .date(.numeric, locale: Locale(identifier: "en_GB"), timeZone: .gmt)
default: fatalError("We found another one!")
}
}
적용하기
let regex = #/
(?<date> \\d{2} / \\d{2} / \\d{4})
(?<middle> \\P{currencySymbol}+)
(?<currency> \\p{currencySymbol})
/#
// Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
func pickStrategy(_ currency: Substring) -> Date.ParseStrategy { … }
ledger.replace(regex) { match -> String in
let date = try! Date(String(match.date), strategy: pickStrategy(match.currency))
// ISO 8601, it's the only way to be sure
let newDate = date.formatted(.iso8601.year().month().day())
return newDate + match.middle + match.currency
}
유니코드 레벨의 매칭 → 기본적으로 유니코드 단위로 동작한다.
switch ("🧟♀️💖🧠", "The Brain Cafe\\u{301}") {
case (/.\\N{SPARKLING HEART}./, /.*café/.ignoresCase()): /
print("Oh no! 🧟♀️💖🧠, but 🧠💖☕️!")
default:
print("No conflicts found")
}
let input = "Oh no! 🧟♀️💖🧠, but 🧠💖☕️!"
input.firstMatch(of: /.\\N{SPARKLING HEART}./)
// 🧟♀️💖🧠
input.firstMatch(of: /.\\N{SPARKLING HEART}./.matchingSemantics(.unicodeScalar))
// ️💖🧠, 첫번째 이모지의 마지막 unicode scalar
캡처를 선택적으로 하기
// CREDIT <proprietary> <redacted> 200.23 A1B34EFF ...
let fieldSeparator = /\\s{2,}|\\t/
let field = OneOrMore {
NegativeLookahead { fieldSeparator }
CharacterClass.any
}
let transactionMatcher = Regex {
Capture { /CREDIT|DEBIT/ }
fieldSeparator
TryCapture(field) { timestamp ~= $0 ? $0 : nil }
fieldSeparator
TryCapture(field) { details ~= $0 ? $0 : nil }
fieldSeparator
// ...
}
캡쳐 범위를 좁하기
// CREDIT <proprietary> <redacted> 200.23 A1B34EFF ...
let fieldSeparator = Local { /\\s{2,}|\\t/ }
let field = OneOrMore {
NegativeLookahead { fieldSeparator } // 뒤쪽 공백을 무시하게 만든다.
CharacterClass.any
}
let transactionMatcher = Regex {
Capture { /CREDIT|DEBIT/ }
fieldSeparator
TryCapture(field) { timestamp ~= $0 ? $0 : nil }
fieldSeparator
TryCapture(field) { details ~= $0 ? $0 : nil }
fieldSeparator
// ...
}