krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Agenda/AgendaSearch.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgCore/Agenda/AgendaSearch.swift history · blame · raw

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}