Sapling/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift

150 lines
5.2 KiB
Swift
Raw Normal View History

import Foundation
public enum HybridMarkdownLineKind: Hashable, Sendable {
case paragraph
case heading(level: Int, markerRange: NSRange, textRange: NSRange)
}
public enum HybridMarkdownSpanKind: Hashable, Sendable {
case bold
case italic
case inlineCode
case markdownDelimiter
}
public struct HybridMarkdownSpan: Hashable, Sendable {
public var range: NSRange
public var kind: HybridMarkdownSpanKind
public init(range: NSRange, kind: HybridMarkdownSpanKind) {
self.range = range
self.kind = kind
}
}
public struct HybridMarkdownLineRenderPlan: Hashable, Sendable {
public var line: EditorLine
public var kind: HybridMarkdownLineKind
public var spans: [HybridMarkdownSpan]
public init(line: EditorLine, kind: HybridMarkdownLineKind, spans: [HybridMarkdownSpan]) {
self.line = line
self.kind = kind
self.spans = spans
}
}
public struct HybridMarkdownLineRenderer: Sendable {
public init() {}
public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan {
let kind = lineKind(for: line)
let spans = inlineSpans(in: line)
return HybridMarkdownLineRenderPlan(line: line, kind: kind, spans: spans)
}
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
let markerCount = line.source.prefix { $0 == "#" }.count
guard (1...6).contains(markerCount),
line.source.dropFirst(markerCount).first == " "
else {
return .paragraph
}
let textOffset = markerCount + 1
return .heading(
level: markerCount,
markerRange: NSRange(location: line.range.location, length: markerCount),
textRange: NSRange(
location: line.range.location + textOffset,
length: max(0, line.range.length - textOffset)
)
)
}
private func inlineSpans(in line: EditorLine) -> [HybridMarkdownSpan] {
guard line.range.length > 0 else { return [] }
var spans: [HybridMarkdownSpan] = []
var excludedRanges: [NSRange] = []
collectMatches("`([^`\\n]+)`", in: line, excluding: excludedRanges).forEach { match in
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .inlineCode))
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
excludedRanges.append(match.fullRange)
}
collectMatches("\\*\\*([^*\\n]+)\\*\\*", in: line, excluding: excludedRanges).forEach { match in
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .bold))
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 2, trailing: 2))
excludedRanges.append(match.fullRange)
}
collectMatches("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)", in: line, excluding: excludedRanges).forEach { match in
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
excludedRanges.append(match.fullRange)
}
return spans.sorted {
if $0.range.location == $1.range.location {
return $0.range.length < $1.range.length
}
return $0.range.location < $1.range.location
}
}
private func collectMatches(
_ pattern: String,
in line: EditorLine,
excluding excludedRanges: [NSRange]
) -> [InlineMatch] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
return regex.matches(in: line.source, range: NSRange(location: 0, length: line.source.utf16.count))
.compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
let shiftedRange = NSRange(
location: line.range.location + match.range.location,
length: match.range.length
)
guard !excludedRanges.contains(where: { $0.intersects(shiftedRange) }) else { return nil }
return InlineMatch(
fullRange: shiftedRange,
contentRange: NSRange(
location: line.range.location + match.range(at: 1).location,
length: match.range(at: 1).length
)
)
}
}
private func delimiterRanges(
_ fullRange: NSRange,
leading: Int,
trailing: Int
) -> [HybridMarkdownSpan] {
[
HybridMarkdownSpan(range: NSRange(location: fullRange.location, length: leading), kind: .markdownDelimiter),
HybridMarkdownSpan(
range: NSRange(location: fullRange.upperBound - trailing, length: trailing),
kind: .markdownDelimiter
)
]
}
}
private struct InlineMatch {
var fullRange: NSRange
var contentRange: NSRange
}
private extension NSRange {
var upperBound: Int {
location + length
}
func intersects(_ other: NSRange) -> Bool {
location < other.upperBound && other.location < upperBound
}
}