Files

370 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
indirect enum MDInline: Sendable {
case text(String)
case code(String)
case strong([MDInline])
case em([MDInline])
case image(src: String, alt: String)
case link(href: String, [MDInline])
case mark(MDMark, [MDInline])
case lineBreak
}
enum MDMark: Sendable {
case ok, warn, danger, muted
}
private let marks: [String: MDMark] = [
"✓": .ok, "✔": .ok, "✔️": .ok, "✅": .ok, "☑": .ok, "☑️": .ok,
"~": .warn, "≈": .warn, "〜": .warn, "⚠": .warn, "⚠️": .warn, "🟡": .warn,
"x": .danger, "X": .danger, "×": .danger, "✗": .danger, "✘": .danger, "✕": .danger, "✖": .danger, "✖️": .danger, "❌": .danger,
"-": .muted, "–": .muted, "—": .muted, "−": .muted,
]
private let boldWrap = Re(#"^(\*\*|__)(.*)\1$"#)
private let leadToken = Re(#"^\s*(\S*)([\s\S]*)$"#)
struct MDItem: Sendable {
var choice: String?
var choiceIndex = 0
var raw = ""
var inline: [MDInline] = []
var blocks: [MDBlock] = []
}
indirect enum MDBlock: Sendable {
case paragraph([MDInline])
case heading(level: Int, [MDInline])
case rule
case quote([MDBlock])
case list(ordered: Bool, start: Int, items: [MDItem], grid: Bool)
case code(String)
case diff([String])
case details(summary: [MDInline], body: [MDBlock])
case diagram(String)
case table(head: [[MDInline]], rows: [[[MDInline]]])
}
struct Re: Sendable {
let re: NSRegularExpression
init(_ pattern: String, caseless: Bool = false) {
re = try! NSRegularExpression(pattern: pattern, options: caseless ? [.caseInsensitive] : [])
}
func test(_ s: String) -> Bool {
re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil
}
func exec(_ s: String) -> [String?]? {
match(s)?.groups
}
func match(_ s: String) -> (range: Range<String.Index>, groups: [String?])? {
guard let m = re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) else {
return nil
}
let groups = (0..<m.numberOfRanges).map { i -> String? in
let r = m.range(at: i)
return r.location == NSNotFound ? nil : String(s[Range(r, in: s)!])
}
return (Range(m.range, in: s)!, groups)
}
}
private let choiceMarker = Re(#"^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$"#)
private let detailsOpen = Re(#"^\s*<details>\s*(?:<summary>(.*?)</summary>)?\s*$"#, caseless: true)
private let summaryLine = Re(#"^\s*<summary>(.*?)</summary>\s*$"#, caseless: true)
private let detailsClose = Re(#"^\s*</details>\s*$"#, caseless: true)
private let fence = Re(#"^\s*(```|~~~)\s*(\S*)"#)
private let heading = Re(#"^(#{1,6})\s+(.*)$"#)
private let bullet = Re(#"^(\s*)[-*+]\s+(.*)$"#)
private let ordered = Re(#"^(\s*)(\d+)[.)]\s+(.*)$"#)
private let quote = Re(#"^\s*>\s?(.*)$"#)
private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#)
private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#)
private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#)
private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![A-Za-z0-9_])_([^_\s][^_]*?)_(?![A-Za-z0-9_])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\((?:<([^>]+)>|([^)\s]+))\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#)
func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] {
let text = src.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
return blocks(text.components(separatedBy: "\n"), choices)
}
func parseInline(_ src: String) -> [MDInline] {
inline(src)
}
private func trimStart(_ s: String) -> Substring {
s.drop { $0 == " " || $0 == "\t" }
}
private func blocks(_ lines: [String], _ choices: Bool) -> [MDBlock] {
var out: [MDBlock] = []
var i = 0
while i < lines.count {
let line = lines[i]
if trimmed(line).isEmpty {
i += 1
continue
}
if let f = fence.exec(line) {
let marker = f[1]!
var body: [String] = []
i += 1
while i < lines.count && !trimStart(lines[i]).hasPrefix(marker) {
body.append(lines[i])
i += 1
}
i += 1
let lang = f[2]!.lowercased()
if lang == "mermaid" {
out.append(.diagram(body.joined(separator: "\n")))
continue
}
if lang == "diff" || lang == "patch" {
out.append(.diff(body))
continue
}
out.append(.code(body.joined(separator: "\n")))
continue
}
if detailsClose.test(line) {
i += 1
continue
}
if let d = detailsOpen.exec(line) {
var title = d[1]
i += 1
if title == nil && i < lines.count, let s = summaryLine.exec(lines[i]) {
title = s[1]
i += 1
}
var body: [String] = []
var depth = 1
while i < lines.count {
let l = lines[i]
i += 1
if detailsOpen.test(l) {
depth += 1
} else if detailsClose.test(l) {
depth -= 1
if depth == 0 {
break
}
}
body.append(l)
}
let summary = title.map(trimmed) ?? ""
out.append(.details(summary: inline(summary.isEmpty ? "Details" : summary), body: blocks(body, choices)))
continue
}
if let hd = heading.exec(line) {
out.append(.heading(level: min(hd[1]!.count + 2, 6), inline(hd[2]!)))
i += 1
continue
}
if rule.test(line) {
out.append(.rule)
i += 1
continue
}
if quote.test(line) {
var body: [String] = []
while i < lines.count, let q = quote.exec(lines[i]) {
body.append(q[1]!)
i += 1
}
out.append(.quote(blocks(body, false)))
continue
}
if bullet.test(line) || ordered.test(line) {
let (list, next) = parseList(lines, i, choices)
out.append(list)
i = next
continue
}
if line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1]) {
var rows: [[String]] = [cells(line)]
i += 2
while i < lines.count && lines[i].contains("|") && !trimmed(lines[i]).isEmpty {
rows.append(cells(lines[i]))
i += 1
}
out.append(.table(head: rows[0].map(cellInline), rows: rows.dropFirst().map { $0.map(cellInline) }))
continue
}
var para: [String] = []
while i < lines.count && !trimmed(lines[i]).isEmpty && !startsBlock(lines, i) {
para.append(trimmed(lines[i]))
i += 1
}
out.append(.paragraph(joinLines(para)))
}
return out
}
private func startsBlock(_ lines: [String], _ i: Int) -> Bool {
let line = lines[i]
return fence.test(line) || detailsOpen.test(line) || detailsClose.test(line) || heading.test(line) || rule.test(line) || quote.test(line) || bullet.test(line) || ordered.test(line)
|| (line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1]))
}
private func joinLines(_ lines: [String]) -> [MDInline] {
var out: [MDInline] = []
for (n, l) in lines.enumerated() {
if n > 0 {
out.append(.lineBreak)
}
out += inline(l)
}
return out
}
private func indentOf(_ line: String) -> Int {
line.count - trimStart(line).count
}
private func parseList(_ lines: [String], _ start: Int, _ choices: Bool) -> (MDBlock, Int) {
let first = lines[start]
let isOrdered = !bullet.test(first)
let base = indentOf(first)
let startNumber = isOrdered ? Int(ordered.exec(first)![2]!) ?? 1 : 1
let marker = isOrdered ? ordered : bullet
var items: [MDItem] = []
var i = start
var imageChoices = !isOrdered && choices
while i < lines.count {
let line = lines[i]
guard let m = marker.exec(line), indentOf(line) == base else {
break
}
let text = isOrdered ? m[3]! : m[2]!
var body = [text]
i += 1
while i < lines.count {
let next = lines[i]
if trimmed(next).isEmpty {
if i + 1 < lines.count, indentOf(lines[i + 1]) > base, !trimmed(lines[i + 1]).isEmpty {
body.append("")
i += 1
continue
}
break
}
if indentOf(next) <= base && (bullet.test(next) || ordered.test(next) || startsBlock(lines, i)) {
break
}
body.append(indentOf(next) > base ? String(next.dropFirst(min(indentOf(next), base + 2))) : trimmed(next))
i += 1
}
let detail = body.dropFirst().filter { !trimmed($0).isEmpty }
imageChoices = imageChoices && choiceMarker.test(text) && detail.count == 1 && imageOnly.test(detail.first!)
items.append(item(body, isOrdered ? false : choices))
while i < lines.count && trimmed(lines[i]).isEmpty && i + 1 < lines.count && indentOf(lines[i + 1]) == base && marker.test(lines[i + 1]) {
i += 1
}
}
return (.list(ordered: isOrdered, start: startNumber, items: items, grid: imageChoices), i)
}
private func item(_ body: [String], _ choices: Bool) -> MDItem {
var out = MDItem(raw: body[0])
if choices, let m = choiceMarker.exec(body[0]) {
out.choice = m[2]
}
let first = out.choice != nil ? [] : [body[0]]
let rest = Array(body.dropFirst())
if rest.indices.allSatisfy({ !startsBlock(body, $0 + 1) }) && !rest.contains("") {
out.inline = joinLines((first + rest).map(trimmed))
return out
}
var head = first
var k = 1
while k < body.count && body[k] != "" && !startsBlock(body, k) {
head.append(trimmed(body[k]))
k += 1
}
out.inline = joinLines(head)
out.blocks = blocks(Array(body[k...]), choices)
return out
}
private func cells(_ line: String) -> [String] {
var s = Substring(trimmed(line))
if s.hasPrefix("|") {
s = s.dropFirst()
}
if s.hasSuffix("|") {
s = s.dropLast()
}
return s.split(separator: "|", omittingEmptySubsequences: false).map { trimmed(String($0)) }
}
private func inline(_ src: String) -> [MDInline] {
if let mark = markOf(src) {
return [.mark(mark, spans(src))]
}
return spans(src)
}
private func markOf(_ src: String) -> MDMark? {
let t = trimmed(src)
return marks[trimmed(boldWrap.exec(t)?[2] ?? t)]
}
private func cellInline(_ src: String) -> [MDInline] {
if markOf(src) != nil {
return inline(src)
}
let g = leadToken.exec(src)!
let lead = g[1] ?? "", rest = g[2] ?? ""
guard let mark = markOf(lead) else {
return spans(src)
}
return [.mark(mark, spans(lead))] + spans(rest)
}
private func spans(_ src: String) -> [MDInline] {
var out: [MDInline] = []
var rest = src
while !rest.isEmpty {
guard let (range, g) = inlineToken.match(rest) else {
out.append(.text(rest))
break
}
if range.lowerBound > rest.startIndex {
out.append(.text(String(rest[..<range.lowerBound])))
}
if let code = g[1] {
out.append(.code(code))
} else if let strong = g[2] ?? g[3] {
out.append(.strong(spans(strong)))
} else if let em = g[4] ?? g[5] {
out.append(.em(spans(em)))
} else if let alt = g[6] {
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
} else if let text = g[9] {
out.append(.link(href: g[10] ?? g[11]!, spans(text)))
} else if let bare = g[12] {
out.append(.link(href: bare, [.text(bare)]))
}
rest = String(rest[range.upperBound...])
}
return out
}