Sources/OrgCore/Semantic/PrettyEntities.swift
54 lines · 3145 bytes
1import Foundation
2
3/// What `org-pretty-entities` changes on a line (`org-fontify-entities`, `org-raise-scripts`):
4/// single-character entities show as that character, and sub/superscripts lose their `_`, `^`
5/// and braces.
6public enum PrettyEntities {
7 public enum Run: Equatable, Sendable {
8 /// `\alpha` or `\alpha{}`, shown as `display`. Offsets are UTF-16, within the line.
9 case entity(range: Range<Int>, display: String)
10 /// A sub/superscript: `markers` (`_` or `^`, and braces) are hidden and `body` raised
11 /// or lowered.
12 case script(markers: [Range<Int>], body: Range<Int>, superscript: Bool)
13 }
14
15 static let entity = try! NSRegularExpression(pattern: "\\\\(there4|sup[123]|frac[13][24]|[a-zA-Z]+)($|\\{\\}|[^\\p{L}\\n])")
16 static let scriptsWithBraces = try! NSRegularExpression(pattern: "\\S([_^])(\\{)[^{}\\n]*(\\})")
17 static let scripts = try! NSRegularExpression(pattern: "\\S([_^])(?:(\\{)[^{}\\n]*(\\})|[*]|[-+]?[^-+*!@#$%^_ \\t\\r\\n,:\"?<>~;./{}=()]+)")
18
19 public static func runs(in line: String, subSuperscriptsNeedBraces: Bool) -> [Run] {
20 guard line.contains(where: { $0 == "\\" || $0 == "_" || $0 == "^" }) else { return [] }
21 let ns = line as NSString
22 let all = NSRange(location: 0, length: ns.length)
23 var runs: [Run] = []
24 for match in entity.matches(in: line, range: all) {
25 guard let display = OrgEntities.display[ns.substring(with: match.range(at: 1))], display.count == 1 else { continue }
26 let end = ns.substring(with: match.range(at: 2)) == "{}" ? NSMaxRange(match.range(at: 2)) : NSMaxRange(match.range(at: 1))
27 runs.append(.entity(range: match.range.location..<end, display: display))
28 }
29 // `org-raise-scripts` searches again from the last character of a match that a `_` or
30 // `^` follows, so `a_b^c` raises both.
31 let regex = subSuperscriptsNeedBraces ? scriptsWithBraces : scripts
32 var matches: [NSTextCheckingResult] = []
33 var from = 0
34 while from < ns.length, let match = regex.firstMatch(in: line, range: NSRange(location: from, length: ns.length - from)) {
35 matches.append(match)
36 let end = NSMaxRange(match.range)
37 from = end < ns.length && (ns.character(at: end) == 95 || ns.character(at: end) == 94) ? end - 1 : end
38 if from <= match.range.location { from = match.range.location + 1 }
39 }
40 for match in matches {
41 let markers = (1...3).compactMap { group -> Range<Int>? in
42 let r = match.range(at: group)
43 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
44 }
45 let marker = match.range(at: 1)
46 let hasBraces = match.range(at: 2).location != NSNotFound
47 let body = hasBraces
48 ? NSMaxRange(match.range(at: 2))..<match.range(at: 3).location
49 : NSMaxRange(marker)..<NSMaxRange(match.range)
50 runs.append(.script(markers: markers, body: body, superscript: ns.substring(with: marker) == "^"))
51 }
52 return runs
53 }
54}