150 lines
5.2 KiB
Swift
150 lines
5.2 KiB
Swift
|
|
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
|
||
|
|
}
|
||
|
|
}
|