aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKit/Parsers/NorgInlineParser.swift
blob: b47ec502bd9bc9aacd00fe4c465ee6f8ee49a21b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import Foundation

/// Converts inline Norg markup into a list of `InlineSpan`s.
public enum NorgInlineParser {

  /// Parses a string to a list of `InlineSpan`s
  public static func parse(_ text: String) -> [InlineSpan] {
    if text.isEmpty { return [] }
    if !text.utf8.contains(where: InlineScanner.significant.contains) {
      return [InlineSpan(text: text, styles: [])]
    }

    let chars = Array(text.unicodeScalars)
    return parse(chars, from: 0, to: chars.count, base: [])
  }

  /// Renders the text as plain text, without styling or markup.
  static func plainText(_ text: Substring) -> String {
    if text.isEmpty { return "" }
    if !text.utf8.contains(where: InlineScanner.significant.contains) {
      return String(text)
    }

    let chars = Array(text.unicodeScalars)
    return parse(chars, from: 0, to: chars.count, base: []).plainText
  }

  private static func parse(_ chars: [Unicode.Scalar], from lo: Int, to hi: Int, base: InlineStyle)
    -> [InlineSpan] {
    var spans: [InlineSpan] = []
    var buffer = String.UnicodeScalarView()

    func flush() {
      guard !buffer.isEmpty else { return }
      spans.append(InlineSpan(text: String(buffer), styles: base))
      buffer = String.UnicodeScalarView()
    }

    var i = lo
    while i < hi {
      guard let object = InlineScanner.object(in: chars, at: i, to: hi) else {
        buffer.append(chars[i])
        i += 1
        continue
      }

      switch object {
      case .escape(let escaped, _):
        if let escaped { buffer.append(chars[escaped]) }

      case .comment:
        flush()

      case .verbatim(let style, _, let body, _, _):
        flush()
        spans.append(InlineSpan(text: slice(chars, body), styles: base.union(style)))

      case .modifier(let style, _, let body, _, _):
        flush()
        spans.append(
          contentsOf: parse(
            chars, from: body.lowerBound, to: body.upperBound, base: base.union(style)))

      case .link(_, let target, let description, _):
        flush()
        let targetText = slice(chars, target.body)
        let label = description.map { slice(chars, $0.body) } ?? linkLabel(targetText)
        spans.append(
          InlineSpan(text: label, styles: base, link: InlineLink(kind: .link, target: targetText)))

      case .anchor(_, let name, let suffix, _):
        flush()
        let nameText = slice(chars, name.body)
        let label: String
        let target: String?
        switch suffix {
        case .target(let bracket):
          label = nameText
          target = slice(chars, bracket.body)
        case .description(let bracket):
          label = slice(chars, bracket.body)
          target = nil
        case nil:
          label = nameText
          target = nil
        }
        spans.append(
          InlineSpan(text: label, styles: base, link: InlineLink(kind: .anchor, target: target)))
      }

      i = object.end
    }

    flush()
    return spans
  }

  // MARK: - Helpers

  /// Builds a `String` from a half-open scalar range.
  private static func slice(_ chars: [Unicode.Scalar], _ range: Range<Int>) -> String {
    String(String.UnicodeScalarView(chars[range]))
  }

  /// Produces display text for a link target that has no explicit description
  /// by stripping the leading location prefix (`*`, `#`, `/`, `$`, `:file:`).
  private static func linkLabel(_ target: String) -> String {
    var s = Substring(target)
    // Strip a leading `:path:` file specifier.
    if s.first == ":", let end = s.dropFirst().firstIndex(of: ":") {
      s = s[s.index(after: end)...]
    }
    s = s.drop { "*#/$ ".contains($0) }
    return s.isEmpty ? target : String(s)
  }
}