krz/orgstar

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

Sources/OrgCore/Parser/Inline.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgCore/Parser/Inline.swift history · blame · raw

433 lines · 16685 bytes

  1/// Characters allowed before an emphasis opener, besides whitespace and the start of the run.
  2private let emphasisPre: Set<Unicode.Scalar> = ["-", "(", "{", "'", "\""]
  3
  4/// Characters allowed after an emphasis closer, besides whitespace and the end of the run.
  5private let emphasisPost: Set<Unicode.Scalar> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["]
  6
  7private let angleLinkSchemes: Set<String> = [
  8    "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment",
  9]
 10
 11private let plainLinkPrefixes: [[Unicode.Scalar]] = ["https://", "http://", "mailto:", "file:"].map { Array($0.unicodeScalars) }
 12
 13private func emphasisKind(_ c: Unicode.Scalar) -> SyntaxKind? {
 14    switch c {
 15    case "*": .bold
 16    case "/": .italic
 17    case "_": .underline
 18    case "+": .strikeThrough
 19    case "=": .verbatim
 20    case "~": .code
 21    default: nil
 22    }
 23}
 24
 25func isNewline(_ c: Unicode.Scalar) -> Bool {
 26    c == "\n"
 27}
 28
 29func isSpace(_ c: Unicode.Scalar) -> Bool {
 30    c.isASCII ? (c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\u{0B}" || c == "\u{0C}") : c.properties.isWhitespace
 31}
 32
 33func isWordScalar(_ c: Unicode.Scalar) -> Bool {
 34    if c.isASCII {
 35        let v = c.value
 36        return (v >= 48 && v <= 57) || (v >= 65 && v <= 90) || (v >= 97 && v <= 122)
 37    }
 38    return c.properties.isAlphabetic || c.properties.numericType != nil
 39}
 40
 41enum InlineMatch {
 42    case emphasis(SyntaxKind, open: Int, close: Int)
 43    case link(path: Range<Int>, description: Range<Int>?, whole: Range<Int>)
 44    case object(SyntaxKind, Range<Int>)
 45
 46    var end: Int {
 47        switch self {
 48        case .emphasis(_, _, let close): close + 1
 49        case .link(_, _, let whole): whole.upperBound
 50        case .object(_, let range): range.upperBound
 51        }
 52    }
 53}
 54
 55/// Turns a run of text into text, newline and object tokens. Every scalar ends up in exactly
 56/// one token. Works on unicode scalars: every recognizer starts on an ASCII character, so
 57/// grapheme clusters never need to be formed.
 58struct InlineScanner {
 59    let chars: [Unicode.Scalar]
 60    /// Where each scalar starts in `source`, plus its end, so token text is sliced from the
 61    /// source instead of rebuilt scalar by scalar.
 62    let source: Substring.UnicodeScalarView
 63    let starts: [String.Index]
 64
 65    init(_ text: Substring) {
 66        source = text.unicodeScalars
 67        var chars: [Unicode.Scalar] = []
 68        var starts: [String.Index] = []
 69        chars.reserveCapacity(text.utf8.count)
 70        starts.reserveCapacity(text.utf8.count + 1)
 71        var index = source.startIndex
 72        while index < source.endIndex {
 73            chars.append(source[index])
 74            starts.append(index)
 75            index = source.index(after: index)
 76        }
 77        starts.append(source.endIndex)
 78        self.chars = chars
 79        self.starts = starts
 80    }
 81
 82    func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) {
 83        var textStart = range.lowerBound
 84        var i = range.lowerBound
 85        while i < range.upperBound {
 86            if Self.mayStartObject(chars[i]), let match = match(at: i, in: range, inLink: inLink) {
 87                emitText(textStart..<i, into: &b)
 88                emit(match, into: &b, inLink: inLink)
 89                i = match.end
 90                textStart = i
 91            } else {
 92                i += 1
 93            }
 94        }
 95        emitText(textStart..<range.upperBound, into: &b)
 96    }
 97
 98    /// The first characters any recognizer accepts.
 99    static func mayStartObject(_ c: Unicode.Scalar) -> Bool {
100        switch c {
101        case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~": true
102        default: false
103        }
104    }
105
106    /// Text with each line break as its own newline token; "\r\n" stays one token.
107    func emitText(_ range: Range<Int>, into b: inout GreenBuilder) {
108        var start = range.lowerBound
109        var k = range.lowerBound
110        while k < range.upperBound {
111            if chars[k] == "\n" {
112                let breakStart = k > start && chars[k - 1] == "\r" ? k - 1 : k
113                if start < breakStart { b.token(.text, string(start..<breakStart)) }
114                b.token(.newline, string(breakStart..<(k + 1)))
115                start = k + 1
116            }
117            k += 1
118        }
119        if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) }
120    }
121
122    func emit(_ match: InlineMatch, into b: inout GreenBuilder, inLink: Bool) {
123        switch match {
124        case .emphasis(let kind, let open, let close):
125            b.start(kind)
126            b.token(.marker, string(open..<(open + 1)))
127            if kind == .verbatim || kind == .code {
128                emitText((open + 1)..<close, into: &b)
129            } else {
130                scan((open + 1)..<close, into: &b, inLink: inLink)
131            }
132            b.token(.marker, string(close..<(close + 1)))
133            b.finish()
134        case .link(let path, let description, let whole):
135            b.start(.link)
136            b.token(.marker, string(whole.lowerBound..<path.lowerBound))
137            b.token(.linkPath, string(path))
138            if let description {
139                b.token(.marker, string(path.upperBound..<description.lowerBound))
140                b.start(.linkDescription)
141                scan(description, into: &b, inLink: true)
142                b.finish()
143                b.token(.marker, string(description.upperBound..<whole.upperBound))
144            } else {
145                b.token(.marker, string(path.upperBound..<whole.upperBound))
146            }
147            b.finish()
148        case .object(let kind, let range):
149            b.start(kind)
150            emitText(range, into: &b)
151            b.finish()
152        }
153    }
154
155    func string(_ range: Range<Int>) -> String {
156        String(source[starts[range.lowerBound]..<starts[range.upperBound]])
157    }
158
159    func hasPrefix(_ s: [Unicode.Scalar], at i: Int, _ limit: Int) -> Bool {
160        guard i + s.count <= limit else { return false }
161        for (offset, c) in s.enumerated() where chars[i + offset] != c { return false }
162        return true
163    }
164
165    func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool {
166        hasPrefix(Array(s.unicodeScalars), at: i, limit)
167    }
168
169    // MARK: - Recognizers
170
171    func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? {
172        let limit = range.upperBound
173        let previous: Unicode.Scalar? = i > range.lowerBound ? chars[i - 1] : nil
174        let afterWord = previous.map(isWordScalar) ?? false
175
176        switch chars[i] {
177        case "[":
178            if !inLink, let link = bracketLink(i, limit) { return link }
179            if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
180            if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
181            if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
182        case "<":
183            if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
184            if let end = target(i, limit) { return .object(.target, i..<end) }
185            if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) }
186        case "{":
187            if let end = macro(i, limit) { return .object(.macro, i..<end) }
188        case "\\":
189            if let end = lineBreak(i, limit) { return .object(.lineBreak, i..<end) }
190            if let end = latexFragment(i, limit) { return .object(.latexFragment, i..<end) }
191        case "^":
192            if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) }
193        case "s":
194            if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) }
195        case "h", "m", "f":
196            if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
197        default:
198            break
199        }
200
201        if let kind = emphasisKind(chars[i]),
202           previous.map({ isSpace($0) || emphasisPre.contains($0) }) ?? true,
203           let close = emphasisClose(i, limit) {
204            return .emphasis(kind, open: i, close: close)
205        }
206        return nil
207    }
208
209    /// org's emphasis rules: the body neither starts nor ends with whitespace, spans at most
210    /// one line break, and the closer is followed by whitespace, punctuation or the end.
211    func emphasisClose(_ i: Int, _ limit: Int) -> Int? {
212        let marker = chars[i]
213        guard i + 1 < limit, !isSpace(chars[i + 1]) else { return nil }
214        var newlines = 0
215        var j = i + 1
216        while j < limit {
217            if isNewline(chars[j]) {
218                newlines += 1
219                if newlines > 1 { return nil }
220            } else if chars[j] == marker, j > i + 1, !isSpace(chars[j - 1]) {
221                if j + 1 == limit || isSpace(chars[j + 1]) || emphasisPost.contains(chars[j + 1]) { return j }
222            }
223            j += 1
224        }
225        return nil
226    }
227
228    /// `[[path]]` or `[[path][description]]`.
229    func bracketLink(_ i: Int, _ limit: Int) -> InlineMatch? {
230        guard i + 1 < limit, chars[i + 1] == "[" else { return nil }
231        var j = i + 2
232        while j < limit, chars[j] != "]" {
233            if chars[j] == "[" || isNewline(chars[j]) { return nil }
234            if chars[j] == "\\", j + 1 < limit { j += 1 }
235            j += 1
236        }
237        guard j > i + 2, j + 1 < limit else { return nil }
238        let path = (i + 2)..<j
239        if chars[j + 1] == "]" { return .link(path: path, description: nil, whole: i..<(j + 2)) }
240        guard chars[j + 1] == "[" else { return nil }
241        let descriptionStart = j + 2
242        var depth = 0
243        var k = descriptionStart
244        while k < limit {
245            if chars[k] == "[" {
246                depth += 1
247            } else if chars[k] == "]" {
248                if depth == 0 { break }
249                depth -= 1
250            }
251            k += 1
252        }
253        guard k > descriptionStart, k + 1 < limit, chars[k + 1] == "]" else { return nil }
254        return .link(path: path, description: descriptionStart..<k, whole: i..<(k + 2))
255    }
256
257    /// `[fn:label]`, `[fn:label:definition]` or `[fn::definition]`.
258    func footnoteReference(_ i: Int, _ limit: Int) -> Int? {
259        guard hasPrefix("[fn:", at: i, limit) else { return nil }
260        var j = i + 4
261        while j < limit, isWordScalar(chars[j]) || chars[j] == "_" || chars[j] == "-" { j += 1 }
262        guard j < limit else { return nil }
263        if chars[j] == "]" { return j > i + 4 ? j + 1 : nil }
264        guard chars[j] == ":" else { return nil }
265        var depth = 0
266        j += 1
267        while j < limit {
268            if chars[j] == "[" {
269                depth += 1
270            } else if chars[j] == "]" {
271                if depth == 0 { return j + 1 }
272                depth -= 1
273            }
274            j += 1
275        }
276        return nil
277    }
278
279    /// `[1/3]`, `[/]`, `[50%]` or `[%]`.
280    func statisticsCookie(_ i: Int, _ limit: Int) -> Int? {
281        var j = i + 1
282        while j < limit, isASCIIDigit(chars[j]) { j += 1 }
283        guard j < limit else { return nil }
284        if chars[j] == "%" {
285            j += 1
286        } else if chars[j] == "/" {
287            j += 1
288            while j < limit, isASCIIDigit(chars[j]) { j += 1 }
289        } else {
290            return nil
291        }
292        guard j < limit, chars[j] == "]" else { return nil }
293        return j + 1
294    }
295
296    /// `<<target>>`.
297    func target(_ i: Int, _ limit: Int) -> Int? {
298        guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil }
299        let start = i + 2
300        var j = start
301        while j < limit, chars[j] != ">" {
302            if chars[j] == "<" || isNewline(chars[j]) { return nil }
303            j += 1
304        }
305        guard j > start, j + 1 < limit, chars[j + 1] == ">",
306              !isSpace(chars[start]), !isSpace(chars[j - 1]) else { return nil }
307        return j + 2
308    }
309
310    /// `<scheme:path>` for a known scheme.
311    func angleLink(_ i: Int, _ limit: Int) -> Int? {
312        var j = i + 1
313        while j < limit, chars[j].properties.isAlphabetic { j += 1 }
314        guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil }
315        j += 1
316        let bodyStart = j
317        while j < limit, chars[j] != ">" {
318            if chars[j] == "<" || isNewline(chars[j]) { return nil }
319            j += 1
320        }
321        guard j < limit, j > bodyStart else { return nil }
322        return j + 1
323    }
324
325    /// A bare URL. Trailing sentence punctuation stays outside the link.
326    func plainLink(_ i: Int, _ limit: Int) -> Int? {
327        guard let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
328        let bodyStart = i + prefix.count
329        var j = bodyStart
330        while j < limit, !isSpace(chars[j]), !"()<>[]\"".unicodeScalars.contains(chars[j]) { j += 1 }
331        while j > bodyStart, ".,;:!?'".unicodeScalars.contains(chars[j - 1]) { j -= 1 }
332        return j > bodyStart ? j : nil
333    }
334
335    /// `{{{name}}}` or `{{{name(arguments)}}}`.
336    func macro(_ i: Int, _ limit: Int) -> Int? {
337        guard hasPrefix("{{{", at: i, limit) else { return nil }
338        var j = i + 3
339        guard j < limit, chars[j].properties.isAlphabetic else { return nil }
340        while j < limit, isWordScalar(chars[j]) || chars[j] == "-" || chars[j] == "_" { j += 1 }
341        if j < limit, chars[j] == "(" {
342            var k = j + 1
343            while k < limit, !hasPrefix(")}}}", at: k, limit) {
344                if isNewline(chars[k]) { return nil }
345                k += 1
346            }
347            return k < limit ? k + 4 : nil
348        }
349        return hasPrefix("}}}", at: j, limit) ? j + 3 : nil
350    }
351
352    /// `\\` at the end of a line, before optional trailing blanks. The line break stays outside.
353    func lineBreak(_ i: Int, _ limit: Int) -> Int? {
354        guard hasPrefix("\\\\", at: i, limit) else { return nil }
355        var j = i + 2
356        while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 }
357        if j + 1 < limit, chars[j] == "\r", chars[j + 1] == "\n" { return j }
358        guard j == limit || isNewline(chars[j]) else { return nil }
359        return j
360    }
361
362    /// `\(...\)` or `\[...\]`.
363    func latexFragment(_ i: Int, _ limit: Int) -> Int? {
364        guard i + 1 < limit else { return nil }
365        let closer: String
366        switch chars[i + 1] {
367        case "(": closer = "\\)"
368        case "[": closer = "\\]"
369        default: return nil
370        }
371        let closing = Array(closer.unicodeScalars)
372        var j = i + 2
373        while j < limit {
374            if hasPrefix(closing, at: j, limit) { return j + 2 }
375            j += 1
376        }
377        return nil
378    }
379
380    /// `^word` or `^{group}` after a letter or digit.
381    func superscript(_ i: Int, _ limit: Int) -> Int? {
382        var j = i + 1
383        guard j < limit else { return nil }
384        if chars[j] == "{" {
385            j += 1
386            while j < limit, chars[j] != "}" {
387                if isNewline(chars[j]) { return nil }
388                j += 1
389            }
390            return j < limit ? j + 1 : nil
391        }
392        let start = j
393        while j < limit, isWordScalar(chars[j]) { j += 1 }
394        return j > start ? j : nil
395    }
396
397    /// `src_lang{body}` or `src_lang[headers]{body}`, on one line, with balanced braces.
398    func inlineSourceBlock(_ i: Int, _ limit: Int) -> Int? {
399        guard hasPrefix("src_", at: i, limit) else { return nil }
400        var j = i + 4
401        let languageStart = j
402        while j < limit, !isSpace(chars[j]), chars[j] != "[", chars[j] != "{" { j += 1 }
403        guard j > languageStart, j < limit else { return nil }
404        if chars[j] == "[" {
405            while j < limit, chars[j] != "]" {
406                if isNewline(chars[j]) { return nil }
407                j += 1
408            }
409            guard j < limit else { return nil }
410            j += 1
411        }
412        guard j < limit, chars[j] == "{" else { return nil }
413        var depth = 0
414        while j < limit {
415            if isNewline(chars[j]) { return nil }
416            if chars[j] == "{" {
417                depth += 1
418            } else if chars[j] == "}" {
419                depth -= 1
420                if depth == 0 { return j + 1 }
421            }
422            j += 1
423        }
424        return nil
425    }
426}
427
428extension Parser {
429    mutating func inline(_ text: Substring) {
430        let scanner = InlineScanner(text)
431        scanner.scan(0..<scanner.chars.count, into: &builder)
432    }
433}