Sources/OrgCore/Agenda/AgendaSearch.swift
560 lines · 26881 bytes
1import Foundation
2
3extension Agenda {
4 /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of
5 /// the keywords.
6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
7 let selected = keywords.map { $0.split(separator: "|").map(String.init) }
8 var items: [AgendaItem] = []
9 for source in sources {
10 for heading in source.headings where !heading.skipped {
11 guard let todo = heading.todo else { continue }
12 if let selected {
13 guard selected.contains(todo) else { continue }
14 } else if heading.isDone {
15 continue
16 }
17 items.append(format(
18 source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil,
19 trailing: "", prefix: options.prefix("todo"), urgency: { 1 + $0 }
20 ))
21 }
22 }
23 return sortByUrgency(markBlocked(items, options: options))
24 }
25
26 /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps
27 /// unfinished TODO entries, as `M` does.
28 public static func tagsMatch(
29 _ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current,
30 options: AgendaOptions = AgendaOptions()
31 ) -> [AgendaItem] {
32 let matcher = TagsMatcher(match, todoOnly: todoOnly)
33 var items: [AgendaItem] = []
34 for source in sources {
35 for heading in source.headings {
36 if matcher.todoOnly, heading.todo == nil { continue }
37 guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue }
38 items.append(format(
39 source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil,
40 trailing: "", prefix: options.prefix("tags"), urgency: { $0 }
41 ))
42 }
43 }
44 return sortByUrgency(markBlocked(items, options: options))
45 }
46
47 /// `org-search-view`: entries, heading and body, containing `string` as a phrase, or with
48 /// a leading `+`, `-` or `{` (or `searchAlwaysBoolean`) matching each of its snippets. A
49 /// leading `*` searches headlines only, `!` keeps unfinished TODO entries as `todoOnly`
50 /// does, and `:` matches whole words. Text before the first heading is not searched.
51 public static func textSearch(_ sources: [AgendaSource], string: String, todoOnly: Bool = false, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
52 let query = SearchQuery(string, todoOnly: todoOnly, options: options)
53 // Org searches for the longest positive snippet, then checks the entry it is in.
54 var positive = query.positive
55 let first = positive.isEmpty ? nil : positive.removeFirst()
56 let patterns = positive.compactMap(\.regex)
57 let negative = query.negative.compactMap(\.regex)
58 guard let search = SearchQuery.compile(first.map { (query.headlinesOnly ? "^\\*+ .*?" : "") + $0.emacs } ?? "^\\*+ "),
59 patterns.count == positive.count, negative.count == query.negative.count else { return [] }
60 let headingLine = try! NSRegularExpression(pattern: "^\\*+ ", options: .anchorsMatchLines)
61 var items: [AgendaItem] = []
62 for source in sources {
63 let text = source.text
64 let ns = text as NSString
65 let length = ns.length
66 let starts = headingLine.matches(in: text, range: NSRange(location: 0, length: length)).map(\.range.location)
67 guard let firstHeading = starts.first else { continue }
68 let byStart = Dictionary(source.headings.map { ($0.start, $0) }, uniquingKeysWith: { a, _ in a })
69 var required = patterns
70 if query.todoOnly {
71 guard !source.notDoneKeywords.isEmpty else { continue }
72 let keywords = source.notDoneKeywords.map(SearchQuery.quote).joined(separator: "\\|")
73 required.insert(SearchQuery.compile("^\\*+[ \\t]+\\(" + keywords + "\\)")!, at: 0)
74 }
75 func lineStart(_ at: Int) -> Int {
76 let newline = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: at))
77 return newline.location == NSNotFound ? 0 : newline.location + 1
78 }
79 var at = max(0, firstHeading - 1)
80 while at <= length, let m = search.firstMatch(in: text, options: [.withoutAnchoringBounds, .withTransparentBounds], range: NSRange(location: at, length: length - at)) {
81 let matchEnd = NSMaxRange(m.range)
82 // `org-back-to-heading`, then `outline-next-heading`.
83 guard let index = starts.lastIndex(where: { $0 <= lineStart(matchEnd) }) else { break }
84 let beg = starts[index]
85 let end = index + 1 < starts.count ? starts[index + 1] : length
86 let next = max(end - 1, at + 1)
87 guard let entry = byStart[beg] else {
88 at = next
89 continue
90 }
91 if entry.skipped {
92 // `org-agenda-skip` leaves point at the end of the subtree, before blank lines.
93 var stop = starts[(index + 1)...].first { byStart[$0].map { $0.level <= entry.level } ?? false } ?? length
94 if stop > 0, [10, 13].contains(ns.character(at: stop - 1)) {
95 stop -= 1
96 while stop > beg, [9, 10, 13, 32].contains(ns.character(at: stop - 1)) { stop -= 1 }
97 }
98 at = max(stop, at + 1)
99 continue
100 }
101 let lineEnd = ns.range(of: "\n", range: NSRange(location: beg, length: length - beg)).location
102 let stop = query.headlinesOnly && lineEnd != NSNotFound ? lineEnd : end
103 let entryText = ns.substring(with: NSRange(location: beg, length: stop - beg))
104 func found(_ regex: NSRegularExpression) -> Bool {
105 regex.firstMatch(in: entryText, range: NSRange(location: 0, length: (entryText as NSString).length)) != nil
106 }
107 if !negative.contains(where: found), required.allSatisfy(found) {
108 items.append(format(
109 source, entry, kind: .search, marker: beg, extra: "", dotime: .headline, removing: nil,
110 trailing: "", prefix: options.prefix("search"), urgency: { _ in 1000 }
111 ))
112 at = max(next, matchEnd)
113 } else {
114 at = next
115 }
116 }
117 }
118 return items
119 }
120
121 /// An `org-search-view` string read into regexps, in Emacs syntax and compiled.
122 struct SearchQuery {
123 struct Pattern {
124 let emacs: String
125 let regex: NSRegularExpression?
126 }
127
128 let headlinesOnly: Bool
129 let todoOnly: Bool
130 let boolean: Bool
131 /// Longest first, as Org sorts them.
132 let positive: [Pattern]
133 let negative: [Pattern]
134
135 init(_ string: String, todoOnly: Bool, options: AgendaOptions) {
136 var words = Substring(string)
137 headlinesOnly = words.first == "*"
138 if headlinesOnly { words.removeFirst() }
139 var todo = todoOnly
140 if words.first == "!" {
141 todo = true
142 words.removeFirst()
143 }
144 self.todoOnly = todo
145 var fullWords = options.searchForceFullWords
146 if words.first == ":" {
147 fullWords = true
148 words.removeFirst()
149 }
150 boolean = options.searchAlwaysBoolean || ["-", "+", "{"].contains(words.first)
151
152 // `split-string`, then words ending in `\` joined to the next, and `{…}` kept whole.
153 var pending = words.split(whereSeparator: { " \u{0C}\t\n\r\u{0B}".contains($0) }).map(String.init)
154 var joined: [String] = []
155 while !pending.isEmpty {
156 var w = pending.removeFirst()
157 while w.hasSuffix("\\"), !pending.isEmpty { w = String(w.dropLast()) + " " + pending.removeFirst() }
158 joined.append(w)
159 }
160 pending = joined
161 joined = []
162 while !pending.isEmpty {
163 var w = pending.removeFirst()
164 if w.range(of: "^[-+]?\\{", options: .regularExpression) != nil, !w.hasSuffix("}") {
165 while let next = pending.first, !next.hasSuffix("}") { w += " " + pending.removeFirst() }
166 w += " " + (pending.isEmpty ? "" : pending.removeFirst())
167 }
168 joined.append(w)
169 }
170 if boolean {
171 pending = joined
172 joined = []
173 while !pending.isEmpty {
174 var w = pending.removeFirst()
175 let chars = Array(w)
176 if chars[0] == "\"" || (chars.count > 1 && "+-".contains(chars[0]) && chars[1] == "\"") {
177 while !pending.isEmpty, !w.hasSuffix("\"") { w += " " + pending.removeFirst() }
178 }
179 w = w.replacingOccurrences(of: "^([-+]?)\"", with: "$1", options: .regularExpression)
180 if w.hasSuffix("\"") { w.removeLast() }
181 joined.append(w)
182 }
183 }
184
185 var positive: [String] = []
186 var negative: [String] = []
187 if boolean {
188 for word in joined {
189 var w = Substring(word)
190 let negated = w.first == "-"
191 if negated || w.first == "+" { w = w.dropFirst() }
192 let re: String
193 if w.count >= 2, w.first == "{", w.last == "}" {
194 re = String(w.dropFirst().dropLast())
195 } else {
196 re = fullWords ? "\\<" + Self.quote(w.lowercased()) + "\\>" : Self.quote(w.lowercased())
197 }
198 if negated { negative.insert(re, at: 0) } else { positive.insert(re, at: 0) }
199 }
200 } else {
201 positive = [joined.map(Self.quote).joined(separator: "\\s-+")]
202 }
203 // `sort` is stable; the snippets were pushed, so the last comes first among equals.
204 self.positive = positive.enumerated().sorted { a, b in
205 a.element.count != b.element.count ? a.element.count > b.element.count : a.offset < b.offset
206 }.map { Pattern(emacs: $0.element, regex: Self.compile($0.element)) }
207 self.negative = negative.map { Pattern(emacs: $0, regex: Self.compile($0)) }
208 }
209
210 /// `regexp-quote`.
211 static func quote(_ s: String) -> String {
212 var out = ""
213 for c in s {
214 if "[*.\\?+^$".contains(c) { out.append("\\") }
215 out.append(c)
216 }
217 return out
218 }
219
220 /// Case-insensitive, as Org binds `case-fold-search`, with `^` and `$` at each line.
221 static func compile(_ emacs: String) -> NSRegularExpression? {
222 try? NSRegularExpression(pattern: EmacsRegex.translate(emacs), options: [.caseInsensitive, .anchorsMatchLines])
223 }
224 }
225
226 /// `(urgency-down category-keep)`, stably.
227 static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] {
228 items.enumerated().sorted { a, b in
229 a.element.urgency != b.element.urgency ? a.element.urgency > b.element.urgency : a.offset < b.offset
230 }.map(\.element)
231 }
232}
233
234/// `org-make-tags-matcher`: `+work-boss|LEVEL>2+TODO="WAIT"/!NEXT`.
235public struct TagsMatcher: Sendable {
236 indirect enum Term: Sendable {
237 case tag(String)
238 case tagRegex(String)
239 case property(name: String, op: String, value: Value, needsValue: Bool)
240 case not(Term)
241 }
242
243 enum Value: Sendable {
244 case regex(String)
245 case string(String)
246 case time(String)
247 case number(Double)
248 }
249
250 /// Alternatives of conjunctions; nil matches everything.
251 let tags: [[Term]]?
252 let todo: [[(regex: Bool, keyword: String, negated: Bool)]]?
253 let todoOnly: Bool
254
255 static let term = try! NSRegularExpression(pattern:
256 "^&?([-+:])?(\\{[^}]+\\}|(?:((?:[\\p{L}\\p{N}_]|\\\\[^\\s])+)([<=>]=?|[!/]=|<>)(\\*)?(\\{[^}]+\\}|\"[^\"]*\"|-?[.0-9]+(?:[eE][-+]?[0-9]+)?))|[\\p{L}\\p{N}_@#%]+)"
257 )
258 static let timeValue = try! NSRegularExpression(pattern: "^\"[\\[<](?:[0-9]+|now|today|tomorrow|[+-][0-9]+[dmwy]).*[\\]>]\"$")
259
260 public init(_ match: String, todoOnly: Bool = false) {
261 var only = todoOnly
262 var tagsPart = match
263 var todoPart: String?
264 let ns = match as NSString
265 let slashes = try! NSRegularExpression(pattern: "/+")
266 if let last = slashes.matches(in: match, range: NSRange(location: 0, length: ns.length)).last,
267 !ns.substring(from: last.range.location).contains("\"") {
268 tagsPart = ns.substring(to: last.range.location)
269 var rest = ns.substring(from: NSMaxRange(last.range))
270 if rest.hasPrefix("!") {
271 only = true
272 rest.removeFirst()
273 }
274 todoPart = rest.trimmingCharacters(in: .whitespaces).isEmpty ? nil : rest
275 }
276 self.todoOnly = only
277
278 func terms(_ s: String) -> [(sign: String?, match: NSTextCheckingResult, text: NSString)] {
279 var result: [(String?, NSTextCheckingResult, NSString)] = []
280 var rest = s
281 while let m = Self.term.firstMatch(in: rest, range: NSRange(location: 0, length: (rest as NSString).length)) {
282 let text = rest as NSString
283 let sign = m.range(at: 1).location != NSNotFound ? text.substring(with: m.range(at: 1)) : nil
284 result.append((sign, m, text))
285 rest = text.substring(from: NSMaxRange(m.range))
286 if m.range.length == 0 { break }
287 }
288 return result
289 }
290
291 if tagsPart.contains(where: { !$0.isWhitespace }) {
292 var alternatives: [[Term]] = []
293 let parts = Self.split(tagsPart)
294 var i = 0
295 while i < parts.count {
296 var part = parts[i]
297 // A `\|` inside a term is not a separator.
298 while part.hasSuffix("\\"), i + 1 < parts.count {
299 i += 1
300 part += "|" + parts[i]
301 }
302 var conjunction: [Term] = []
303 for (sign, m, text) in terms(part) {
304 let whole = text.substring(with: m.range(at: 2))
305 var term: Term
306 if whole.hasPrefix("{") {
307 term = .tagRegex(String(whole.dropFirst().dropLast()))
308 } else if m.range(at: 3).location != NSNotFound {
309 let name = text.substring(with: m.range(at: 3))
310 .replacingOccurrences(of: "\\\\(.)", with: "$1", options: .regularExpression).uppercased()
311 let raw = text.substring(with: m.range(at: 6))
312 let value: Value
313 if raw.hasPrefix("{") {
314 value = .regex(String(raw.dropFirst().dropLast()))
315 } else if raw.hasPrefix("\"") {
316 let inner = String(raw.dropFirst().dropLast())
317 let isTime = Self.timeValue.firstMatch(in: raw, range: NSRange(location: 0, length: (raw as NSString).length)) != nil
318 value = isTime ? .time(inner) : .string(inner)
319 } else {
320 value = .number(Self.number(raw))
321 }
322 term = .property(name: name, op: text.substring(with: m.range(at: 4)), value: value, needsValue: m.range(at: 5).location != NSNotFound)
323 } else {
324 term = .tag(whole)
325 }
326 if sign == "-" { term = .not(term) }
327 conjunction.append(term)
328 }
329 alternatives.append(conjunction)
330 i += 1
331 }
332 tags = alternatives
333 } else {
334 tags = nil
335 }
336
337 if let todoPart {
338 todo = Self.split(todoPart).map { part in
339 terms(part).map { sign, m, text in
340 let keyword = text.substring(with: m.range(at: 2))
341 return keyword.hasPrefix("{")
342 ? (true, String(keyword.dropFirst().dropLast()), sign == "-")
343 : (false, keyword, sign == "-")
344 }
345 }
346 } else {
347 todo = nil
348 }
349 }
350
351 /// `org-split-string` on `|`: empty strings between separators stay.
352 static func split(_ s: String) -> [String] {
353 var parts = s.components(separatedBy: "|")
354 if parts.first == "" { parts.removeFirst() }
355 if parts.last == "", !parts.isEmpty { parts.removeLast() }
356 return parts.isEmpty ? [""] : parts
357 }
358
359 /// `string-to-number`: the leading number, or 0.
360 static func number(_ s: String) -> Double {
361 let trimmed = s.drop { $0 == " " || $0 == "\t" }
362 guard let r = trimmed.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 }
363 return Double(trimmed[r]) ?? 0
364 }
365
366 func matches(_ heading: AgendaSource.Heading, in source: AgendaSource, now: Date, calendar: Calendar) -> Bool {
367 let todoKeyword = heading.todo
368 if todoOnly {
369 guard let todoKeyword, !heading.isDone else { return false }
370 _ = todoKeyword
371 }
372 let tagList = heading.tags.map(\.name)
373 func value(_ name: String) -> String? {
374 switch name {
375 case "LEVEL": return String(heading.level)
376 case "CATEGORY": return heading.category
377 case "TODO": return heading.todo
378 case "ITEM": return heading.title
379 case "PRIORITY": return heading.priority ?? source.priorities.default
380 case "FILE": return source.path
381 case "TAGS": return heading.localTags.isEmpty ? nil : ":" + heading.localTags.joined(separator: ":") + ":"
382 case "ALLTAGS": return tagList.isEmpty ? nil : ":" + tagList.joined(separator: ":") + ":"
383 case "SCHEDULED", "DEADLINE", "CLOSED": return heading.planning[name]
384 case "TIMESTAMP": return heading.firstActive
385 case "TIMESTAMP_IA": return heading.firstInactive
386 default: return heading.properties[name]
387 }
388 }
389 func evaluate(_ term: Term) -> Bool {
390 switch term {
391 case .not(let inner):
392 return !evaluate(inner)
393 case .tag(let tag):
394 return tagList.contains(tag)
395 case .tagRegex(let pattern):
396 return tagList.contains { EmacsRegex.matches(pattern, $0) }
397 case .property(let name, let op, let expected, let needsValue):
398 let found = value(name)
399 if needsValue, found == nil { return false }
400 let actual = found ?? ""
401 switch expected {
402 case .regex(let pattern):
403 let hit = EmacsRegex.matches(pattern, actual)
404 return op == "/=" ? !hit : hit
405 case .string(let string):
406 return Self.compare(op, actual, string)
407 case .number(let number):
408 return Self.compare(op, Self.number(actual), number)
409 case .time(let time):
410 let a = Self.seconds(actual, now: now, calendar: calendar)
411 let b = Self.matcherTime(time, now: now, calendar: calendar)
412 guard a > 0, b > 0 else { return false }
413 // `org-time<>` in Org 9.8.7 (org-macs.el) is `(\= a b)`, which reads as `=`:
414 // `DEADLINE<>"<2026-10-07>"` keeps the entries due that day.
415 // AgendaTests.listsMatchEmacs checks this against Emacs.
416 return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b)
417 }
418 }
419 }
420 if let tags, !tags.contains(where: { $0.allSatisfy(evaluate) }) { return false }
421 if let todo {
422 let ok = todo.contains { conjunction in
423 conjunction.allSatisfy { term in
424 let hit = term.regex
425 ? todoKeyword.map { EmacsRegex.matches(term.keyword, $0) } ?? false
426 : todoKeyword == term.keyword
427 return term.negated ? !hit : hit
428 }
429 }
430 if !ok { return false }
431 }
432 return true
433 }
434
435 static func compare<T: Comparable>(_ op: String, _ a: T, _ b: T) -> Bool {
436 switch op {
437 case "<": a < b
438 case ">": a > b
439 case "<=", "=<": a <= b
440 case ">=", "=>": a >= b
441 case "=", "==": a == b
442 default: a != b
443 }
444 }
445
446 /// `org-2ft`: a timestamp string as seconds, or 0.
447 static func seconds(_ s: String, now: Date, calendar: Calendar) -> Double {
448 let ns = s as NSString
449 guard let m = Days.dateRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return 0 }
450 var c = DateComponents(year: Int(ns.substring(with: m.range(at: 1))), month: Int(ns.substring(with: m.range(at: 2))), day: Int(ns.substring(with: m.range(at: 3))))
451 let hour = Days.hour(of: s)
452 c.hour = hour
453 if let time = try? NSRegularExpression(pattern: " ([0-9]{1,2}):([0-9]{2})").firstMatch(in: s, range: NSRange(location: NSMaxRange(m.range), length: ns.length - NSMaxRange(m.range))), Int(ns.substring(with: time.range(at: 1))) == hour {
454 c.minute = Int(ns.substring(with: time.range(at: 2)))
455 }
456 return calendar.date(from: c)?.timeIntervalSince1970 ?? 0
457 }
458
459 /// `org-matcher-time`.
460 static func matcherTime(_ s: String, now: Date, calendar: Calendar) -> Double {
461 let today = calendar.startOfDay(for: now).timeIntervalSince1970
462 switch s {
463 case "<now>": return now.timeIntervalSince1970
464 case "<today>": return today
465 case "<tomorrow>": return today + 86400
466 case "<yesterday>": return today - 86400
467 default: break
468 }
469 let ns = s as NSString
470 if let m = try! NSRegularExpression(pattern: "^<([-+][0-9]+)([hdwmy])>$").firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) {
471 let n = Double(ns.substring(with: m.range(at: 1)))!
472 let unit = ns.substring(with: m.range(at: 2))
473 let size: Double = ["h": 3600, "d": 86400, "w": 604800, "m": 2678400, "y": 31557600][unit]!
474 return (unit == "h" ? now.timeIntervalSince1970 : today) + n * size
475 }
476 return seconds(s, now: now, calendar: calendar)
477 }
478}
479
480/// Emacs regular expressions run through ICU, case-insensitively as the matcher binds
481/// `case-fold-search`.
482enum EmacsRegex {
483 /// Word syntax in an Org buffer: letters, digits, marks, `$` and `%`, but not `_`, `-` or `'`.
484 static let word = "[\\p{L}\\p{N}\\p{M}$%\\u00b7]"
485
486 static func translate(_ pattern: String) -> String {
487 var out = ""
488 var chars = Array(pattern)
489 var i = 0
490 var inClass = false
491 while i < chars.count {
492 let c = chars[i]
493 if inClass {
494 if c == "]" { inClass = false }
495 if c == "[" , i + 1 < chars.count, chars[i + 1] == ":" {
496 // `[:alpha:]` passes through.
497 if let end = String(chars[i...]).range(of: ":]") {
498 let length = String(chars[i...]).distance(from: String(chars[i...]).startIndex, to: end.upperBound)
499 out += String(chars[i..<(i + length)])
500 i += length
501 continue
502 }
503 }
504 out += c == "\\" ? "\\\\" : String(c)
505 i += 1
506 continue
507 }
508 if c == "\\", i + 1 < chars.count {
509 let n = chars[i + 1]
510 switch n {
511 case "(":
512 if i + 3 < chars.count, chars[i + 2] == "?", chars[i + 3] == ":" {
513 out += "(?:"
514 i += 4
515 continue
516 }
517 out += "("
518 case ")": out += ")"
519 case "|": out += "|"
520 case "{": out += "{"
521 case "}": out += "}"
522 case "s" where i + 2 < chars.count && chars[i + 2] == "-",
523 "S" where i + 2 < chars.count && chars[i + 2] == "-":
524 // Whitespace syntax in Org buffers.
525 out += n == "s" ? "[\\t\\n\\f\\r \\u00a0]" : "[^\\t\\n\\f\\r \\u00a0]"
526 i += 3
527 continue
528 case "<": out += "(?<!\(word))(?=\(word))"
529 case ">": out += "(?<=\(word))(?!\(word))"
530 case "b": out += "(?:(?<!\(word))(?=\(word))|(?<=\(word))(?!\(word)))"
531 case "B": out += "(?:(?<=\(word))(?=\(word))|(?<!\(word))(?!\(word)))"
532 case "`": out += "^"
533 case "'": out += "$"
534 case "w": out += word
535 case "W": out += "[^" + word.dropFirst()
536 default: out += "\\" + String(n)
537 }
538 i += 2
539 continue
540 }
541 switch c {
542 case "(", ")", "|", "{", "}": out += "\\" + String(c)
543 case "[":
544 inClass = true
545 out += "["
546 if i + 1 < chars.count, chars[i + 1] == "^" { out += "^"; i += 1 }
547 if i + 1 < chars.count, chars[i + 1] == "]" { out += "\\]"; i += 1 }
548 default: out += String(c)
549 }
550 i += 1
551 }
552 chars = []
553 return out
554 }
555
556 static func matches(_ pattern: String, _ string: String) -> Bool {
557 guard let regex = try? NSRegularExpression(pattern: translate(pattern), options: .caseInsensitive) else { return false }
558 return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) != nil
559 }
560}