2026-05-31 23:01:11 +02:00
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
public enum RenderedLineState: String, Hashable, Sendable {
|
|
|
|
|
case source
|
|
|
|
|
case rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderedHeadingElement: Hashable, Sendable {
|
|
|
|
|
public var lineIndex: Int
|
|
|
|
|
public var level: Int
|
|
|
|
|
public var markerRange: NSRange
|
|
|
|
|
public var textRange: NSRange
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderedTaskElement: Hashable, Sendable {
|
|
|
|
|
public var lineIndex: Int
|
|
|
|
|
public var markerRange: NSRange
|
|
|
|
|
public var checkboxRange: NSRange
|
|
|
|
|
public var contentRange: NSRange
|
|
|
|
|
public var checked: Bool
|
|
|
|
|
public var nestingLevel: Int
|
2026-06-01 09:13:09 +02:00
|
|
|
|
|
|
|
|
public var toggledMarkdownCheckbox: String {
|
|
|
|
|
checked ? "[ ]" : "[x]"
|
|
|
|
|
}
|
2026-05-31 23:01:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderedCodeBlockElement: Hashable, Sendable {
|
|
|
|
|
public var lineIndexes: [Int]
|
|
|
|
|
public var sourceRange: NSRange
|
|
|
|
|
public var languageRange: NSRange?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderedLinkElement: Hashable, Sendable {
|
|
|
|
|
public var lineIndex: Int
|
|
|
|
|
public var titleRange: NSRange
|
|
|
|
|
public var urlRange: NSRange?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public enum RenderedDocumentElement: Hashable, Sendable {
|
|
|
|
|
case heading(RenderedHeadingElement)
|
|
|
|
|
case task(RenderedTaskElement)
|
|
|
|
|
case codeBlock(RenderedCodeBlockElement)
|
|
|
|
|
case link(RenderedLinkElement)
|
|
|
|
|
case inlineCode(lineIndex: Int, range: NSRange)
|
|
|
|
|
case blockquote(lineIndex: Int, range: NSRange)
|
|
|
|
|
case horizontalRule(lineIndex: Int, range: NSRange)
|
|
|
|
|
case listItem(lineIndex: Int, markerRange: NSRange, contentRange: NSRange, nestingLevel: Int)
|
|
|
|
|
case tableRow(lineIndex: Int, range: NSRange)
|
|
|
|
|
case paragraph(lineIndex: Int, range: NSRange)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
public struct RenderNode: Hashable, Sendable {
|
|
|
|
|
public var element: RenderedDocumentElement
|
|
|
|
|
|
|
|
|
|
public init(element: RenderedDocumentElement) {
|
|
|
|
|
self.element = element
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderedLine: Hashable, Sendable {
|
|
|
|
|
public var line: EditorLine
|
|
|
|
|
public var renderPlan: HybridMarkdownLineRenderPlan
|
|
|
|
|
public var nodes: [RenderNode]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct RenderSnapshot: Hashable, Sendable {
|
|
|
|
|
public var lines: [String]
|
|
|
|
|
|
|
|
|
|
public init(lines: [String]) {
|
|
|
|
|
self.lines = lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var signature: String {
|
|
|
|
|
lines.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct DocumentRenderModel: Hashable, Sendable {
|
|
|
|
|
public var lineCount: Int
|
|
|
|
|
public var lines: [RenderedLine]
|
|
|
|
|
public var nodes: [RenderNode]
|
|
|
|
|
|
|
|
|
|
public init(lineIndex: DocumentLineIndex, lineIndexes: [Int]? = nil) {
|
|
|
|
|
self.lineCount = lineIndex.lineCount
|
|
|
|
|
|
|
|
|
|
let renderer = HybridMarkdownLineRenderer()
|
|
|
|
|
let selectedLines = lineIndexes.map {
|
|
|
|
|
lineIndex.editorLines(for: $0, activeLineIndex: -1)
|
|
|
|
|
} ?? lineIndex.editorLines(activeLineIndex: -1)
|
|
|
|
|
let renderPlans = DocumentPresentationState.renderPlans(
|
|
|
|
|
for: selectedLines,
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
activeLineIndex: -1,
|
|
|
|
|
renderer: renderer,
|
|
|
|
|
needsDocumentContext: lineIndexes != nil
|
|
|
|
|
)
|
|
|
|
|
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
|
|
|
|
var renderedLines: [RenderedLine] = []
|
|
|
|
|
var collectedNodes: [RenderNode] = []
|
|
|
|
|
|
|
|
|
|
for line in selectedLines {
|
|
|
|
|
let renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line)
|
|
|
|
|
let nodes = DocumentPresentationState.elements(for: renderPlan, state: .rendered).map(RenderNode.init)
|
|
|
|
|
renderedLines.append(RenderedLine(line: line, renderPlan: renderPlan, nodes: nodes))
|
|
|
|
|
collectedNodes.append(contentsOf: nodes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if lineIndexes == nil {
|
|
|
|
|
collectedNodes.append(contentsOf: DocumentPresentationState.codeBlockElements(from: renderPlans).map(RenderNode.init))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.lines = renderedLines
|
|
|
|
|
self.nodes = collectedNodes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var snapshot: RenderSnapshot {
|
|
|
|
|
let lineSnapshots = lines.map { renderedLine in
|
|
|
|
|
[
|
|
|
|
|
"line=\(renderedLine.line.index)",
|
|
|
|
|
"range=\(renderedLine.line.range.location):\(renderedLine.line.range.length)",
|
|
|
|
|
"kind=\(Self.describe(renderedLine.renderPlan.kind))",
|
|
|
|
|
"spans=\(renderedLine.renderPlan.spans.map(Self.describe).joined(separator: ","))",
|
|
|
|
|
"nodes=\(renderedLine.nodes.map { Self.describe($0.element) }.joined(separator: ","))"
|
|
|
|
|
].joined(separator: "|")
|
|
|
|
|
}
|
|
|
|
|
let treeSnapshot = "tree=" + nodes.map { Self.describe($0.element) }.joined(separator: "|")
|
|
|
|
|
return RenderSnapshot(lines: lineSnapshots + [treeSnapshot])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func describe(_ kind: HybridMarkdownLineKind) -> String {
|
|
|
|
|
switch kind {
|
|
|
|
|
case .paragraph:
|
|
|
|
|
return "paragraph"
|
|
|
|
|
case .heading(let level, let markerRange, let textRange):
|
|
|
|
|
return "heading:\(level):\(describe(markerRange)):\(describe(textRange))"
|
|
|
|
|
case .blockquote(let markerRange, let contentRange):
|
|
|
|
|
return "blockquote:\(describe(markerRange)):\(describe(contentRange))"
|
|
|
|
|
case .horizontalRule(let range):
|
|
|
|
|
return "horizontalRule:\(describe(range))"
|
|
|
|
|
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
|
|
|
|
|
return "unorderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
|
|
|
|
case .orderedList(let markerRange, let contentRange, let nestingLevel):
|
|
|
|
|
return "orderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
|
|
|
|
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
|
|
|
|
return "task:\(describe(markerRange)):\(describe(checkboxRange)):\(describe(contentRange)):\(checked):\(nestingLevel)"
|
|
|
|
|
case .fencedCodeFence(let markerRange, let languageRange):
|
|
|
|
|
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")"
|
2026-06-01 10:17:17 +02:00
|
|
|
case .codeBlockContent(let language):
|
|
|
|
|
return "codeContent:\(language ?? "plain")"
|
2026-06-01 09:13:09 +02:00
|
|
|
case .tableRow(let cellRanges, let separatorRanges, let isDivider):
|
|
|
|
|
return "table:\(cellRanges.map(describe).joined(separator: ",")):"
|
|
|
|
|
+ "\(separatorRanges.map(describe).joined(separator: ",")):\(isDivider)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func describe(_ span: HybridMarkdownSpan) -> String {
|
|
|
|
|
"\(span.kind):\(describe(span.range))"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func describe(_ element: RenderedDocumentElement) -> String {
|
|
|
|
|
switch element {
|
|
|
|
|
case .heading(let heading):
|
|
|
|
|
return "heading:\(heading.lineIndex):\(heading.level):\(describe(heading.textRange))"
|
|
|
|
|
case .task(let task):
|
|
|
|
|
return "task:\(task.lineIndex):\(describe(task.checkboxRange)):\(task.checked)"
|
|
|
|
|
case .codeBlock(let codeBlock):
|
|
|
|
|
return "codeBlock:\(codeBlock.lineIndexes.map(String.init).joined(separator: ",")):"
|
|
|
|
|
+ "\(describe(codeBlock.sourceRange)):\(codeBlock.languageRange.map(describe) ?? "nil")"
|
|
|
|
|
case .link(let link):
|
|
|
|
|
return "link:\(link.lineIndex):\(describe(link.titleRange)):\(link.urlRange.map(describe) ?? "nil")"
|
|
|
|
|
case .inlineCode(let lineIndex, let range):
|
|
|
|
|
return "inlineCode:\(lineIndex):\(describe(range))"
|
|
|
|
|
case .blockquote(let lineIndex, let range):
|
|
|
|
|
return "blockquote:\(lineIndex):\(describe(range))"
|
|
|
|
|
case .horizontalRule(let lineIndex, let range):
|
|
|
|
|
return "horizontalRule:\(lineIndex):\(describe(range))"
|
|
|
|
|
case .listItem(let lineIndex, let markerRange, let contentRange, let nestingLevel):
|
|
|
|
|
return "listItem:\(lineIndex):\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
|
|
|
|
case .tableRow(let lineIndex, let range):
|
|
|
|
|
return "tableRow:\(lineIndex):\(describe(range))"
|
|
|
|
|
case .paragraph(let lineIndex, let range):
|
|
|
|
|
return "paragraph:\(lineIndex):\(describe(range))"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func describe(_ range: NSRange) -> String {
|
|
|
|
|
"\(range.location):\(range.length)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
public struct DocumentPresentationLine: Hashable, Sendable {
|
|
|
|
|
public var line: EditorLine
|
|
|
|
|
public var state: RenderedLineState
|
|
|
|
|
public var renderPlan: HybridMarkdownLineRenderPlan
|
|
|
|
|
public var elements: [RenderedDocumentElement]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct DocumentPresentationState: Hashable, Sendable {
|
|
|
|
|
public var activeLineIndex: Int
|
2026-06-01 10:17:17 +02:00
|
|
|
public var editableRegion: EditableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
public var lineCount: Int
|
|
|
|
|
public var lines: [DocumentPresentationLine]
|
|
|
|
|
public var elements: [RenderedDocumentElement]
|
|
|
|
|
|
|
|
|
|
public init(
|
|
|
|
|
lineIndex: DocumentLineIndex,
|
|
|
|
|
activeLineIndex: Int,
|
|
|
|
|
lineIndexes: [Int]? = nil
|
|
|
|
|
) {
|
2026-06-01 10:17:17 +02:00
|
|
|
self.init(
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
editableRegion: activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none(),
|
|
|
|
|
lineIndexes: lineIndexes
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public init(
|
|
|
|
|
lineIndex: DocumentLineIndex,
|
|
|
|
|
editableRegion: EditableRegion,
|
|
|
|
|
lineIndexes: [Int]? = nil
|
|
|
|
|
) {
|
|
|
|
|
self.activeLineIndex = editableRegion.primaryLineIndex
|
|
|
|
|
self.editableRegion = editableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
self.lineCount = lineIndex.lineCount
|
|
|
|
|
|
|
|
|
|
let renderer = HybridMarkdownLineRenderer()
|
|
|
|
|
let selectedLines = lineIndexes.map {
|
2026-06-01 10:17:17 +02:00
|
|
|
lineIndex.editorLines(for: $0, activeLineIndex: editableRegion.primaryLineIndex)
|
|
|
|
|
} ?? lineIndex.editorLines(activeLineIndex: editableRegion.primaryLineIndex)
|
2026-05-31 23:01:11 +02:00
|
|
|
let renderPlans = Self.renderPlans(
|
|
|
|
|
for: selectedLines,
|
|
|
|
|
lineIndex: lineIndex,
|
2026-06-01 10:17:17 +02:00
|
|
|
activeLineIndex: editableRegion.primaryLineIndex,
|
2026-05-31 23:01:11 +02:00
|
|
|
renderer: renderer,
|
|
|
|
|
needsDocumentContext: lineIndexes != nil
|
|
|
|
|
)
|
|
|
|
|
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
|
|
|
|
var presentationLines: [DocumentPresentationLine] = []
|
|
|
|
|
var collectedElements: [RenderedDocumentElement] = []
|
|
|
|
|
|
|
|
|
|
for line in selectedLines {
|
2026-06-01 10:17:17 +02:00
|
|
|
let state: RenderedLineState = editableRegion.contains(line.index) ? .source : .rendered
|
2026-05-31 23:01:11 +02:00
|
|
|
let renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line)
|
|
|
|
|
let lineElements = Self.elements(for: renderPlan, state: state)
|
|
|
|
|
presentationLines.append(DocumentPresentationLine(
|
|
|
|
|
line: line,
|
|
|
|
|
state: state,
|
|
|
|
|
renderPlan: renderPlan,
|
|
|
|
|
elements: lineElements
|
|
|
|
|
))
|
|
|
|
|
collectedElements.append(contentsOf: lineElements)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if lineIndexes == nil {
|
|
|
|
|
collectedElements.append(contentsOf: Self.codeBlockElements(from: renderPlans))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.lines = presentationLines
|
|
|
|
|
self.elements = collectedElements
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var renderedTasks: [RenderedTaskElement] {
|
|
|
|
|
elements.compactMap {
|
|
|
|
|
guard case .task(let task) = $0 else { return nil }
|
|
|
|
|
return task
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var renderedCodeBlocks: [RenderedCodeBlockElement] {
|
|
|
|
|
elements.compactMap {
|
|
|
|
|
guard case .codeBlock(let codeBlock) = $0 else { return nil }
|
|
|
|
|
return codeBlock
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func lineState(at lineIndex: Int) -> RenderedLineState? {
|
|
|
|
|
lines.first { $0.line.index == lineIndex }?.state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static func renderedTasks(
|
|
|
|
|
in lineIndex: DocumentLineIndex,
|
|
|
|
|
activeLineIndex: Int
|
|
|
|
|
) -> [RenderedTaskElement] {
|
|
|
|
|
guard lineIndex.source.contains("[ ]")
|
|
|
|
|
|| lineIndex.source.contains("[x]")
|
|
|
|
|
|| lineIndex.source.contains("[X]")
|
|
|
|
|
else {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DocumentPresentationState(
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
).renderedTasks
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
fileprivate static func renderPlans(
|
2026-05-31 23:01:11 +02:00
|
|
|
for lines: [EditorLine],
|
|
|
|
|
lineIndex: DocumentLineIndex,
|
|
|
|
|
activeLineIndex: Int,
|
|
|
|
|
renderer: HybridMarkdownLineRenderer,
|
|
|
|
|
needsDocumentContext: Bool
|
|
|
|
|
) -> [HybridMarkdownLineRenderPlan] {
|
|
|
|
|
if needsDocumentContext {
|
|
|
|
|
guard linesNeedCodeBlockContext(lines, lineIndex: lineIndex) else {
|
|
|
|
|
return renderer.renderPlans(for: lines)
|
|
|
|
|
}
|
|
|
|
|
return renderer.renderPlans(
|
|
|
|
|
for: lines,
|
|
|
|
|
resolvingCodeBlockContextWith: lineIndex,
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return renderer.renderPlans(for: lines)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func linesNeedCodeBlockContext(_ lines: [EditorLine], lineIndex: DocumentLineIndex) -> Bool {
|
|
|
|
|
for line in lines {
|
|
|
|
|
let lowerBound = max(0, line.index - 2)
|
|
|
|
|
let upperBound = min(lineIndex.lineCount - 1, line.index + 2)
|
|
|
|
|
for nearbyLineIndex in lowerBound...upperBound {
|
|
|
|
|
guard let nearbyLine = lineIndex.editorLine(at: nearbyLineIndex, activeLineIndex: -1) else { continue }
|
|
|
|
|
if isFenceLine(nearbyLine.source) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func isFenceLine(_ source: String) -> Bool {
|
|
|
|
|
let trimmedPrefix = source.prefix { $0 == " " || $0 == "\t" }
|
|
|
|
|
guard trimmedPrefix.count <= 3 else { return false }
|
|
|
|
|
let content = source.dropFirst(trimmedPrefix.count)
|
|
|
|
|
return content.hasPrefix("```") || content.hasPrefix("~~~")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
fileprivate static func elements(
|
2026-05-31 23:01:11 +02:00
|
|
|
for renderPlan: HybridMarkdownLineRenderPlan,
|
|
|
|
|
state: RenderedLineState
|
|
|
|
|
) -> [RenderedDocumentElement] {
|
|
|
|
|
guard state == .rendered else { return [] }
|
|
|
|
|
|
|
|
|
|
var elements: [RenderedDocumentElement] = []
|
|
|
|
|
switch renderPlan.kind {
|
|
|
|
|
case .heading(let level, let markerRange, let textRange):
|
|
|
|
|
elements.append(.heading(RenderedHeadingElement(
|
|
|
|
|
lineIndex: renderPlan.line.index,
|
|
|
|
|
level: level,
|
|
|
|
|
markerRange: markerRange,
|
|
|
|
|
textRange: textRange
|
|
|
|
|
)))
|
|
|
|
|
case .blockquote:
|
|
|
|
|
elements.append(.blockquote(lineIndex: renderPlan.line.index, range: renderPlan.line.range))
|
|
|
|
|
case .horizontalRule(let range):
|
|
|
|
|
elements.append(.horizontalRule(lineIndex: renderPlan.line.index, range: range))
|
|
|
|
|
case .unorderedList(let markerRange, let contentRange, let nestingLevel),
|
|
|
|
|
.orderedList(let markerRange, let contentRange, let nestingLevel):
|
|
|
|
|
elements.append(.listItem(
|
|
|
|
|
lineIndex: renderPlan.line.index,
|
|
|
|
|
markerRange: markerRange,
|
|
|
|
|
contentRange: contentRange,
|
|
|
|
|
nestingLevel: nestingLevel
|
|
|
|
|
))
|
|
|
|
|
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
|
|
|
|
elements.append(.task(RenderedTaskElement(
|
|
|
|
|
lineIndex: renderPlan.line.index,
|
|
|
|
|
markerRange: markerRange,
|
|
|
|
|
checkboxRange: checkboxRange,
|
|
|
|
|
contentRange: contentRange,
|
|
|
|
|
checked: checked,
|
|
|
|
|
nestingLevel: nestingLevel
|
|
|
|
|
)))
|
|
|
|
|
case .fencedCodeFence, .codeBlockContent:
|
|
|
|
|
break
|
|
|
|
|
case .tableRow:
|
|
|
|
|
elements.append(.tableRow(lineIndex: renderPlan.line.index, range: renderPlan.line.range))
|
|
|
|
|
case .paragraph:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for span in renderPlan.spans {
|
|
|
|
|
switch span.kind {
|
|
|
|
|
case .inlineCode:
|
|
|
|
|
elements.append(.inlineCode(lineIndex: renderPlan.line.index, range: span.range))
|
|
|
|
|
case .link:
|
|
|
|
|
elements.append(.link(RenderedLinkElement(
|
|
|
|
|
lineIndex: renderPlan.line.index,
|
|
|
|
|
titleRange: span.range,
|
|
|
|
|
urlRange: nil
|
|
|
|
|
)))
|
|
|
|
|
case .automaticLink:
|
|
|
|
|
elements.append(.link(RenderedLinkElement(
|
|
|
|
|
lineIndex: renderPlan.line.index,
|
|
|
|
|
titleRange: span.range,
|
|
|
|
|
urlRange: span.range
|
|
|
|
|
)))
|
|
|
|
|
case .bold, .italic, .markdownDelimiter:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return elements
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
fileprivate static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] {
|
2026-05-31 23:01:11 +02:00
|
|
|
var elements: [RenderedDocumentElement] = []
|
|
|
|
|
var openLineIndexes: [Int] = []
|
|
|
|
|
var openRange: NSRange?
|
|
|
|
|
var openLanguageRange: NSRange?
|
|
|
|
|
|
|
|
|
|
for plan in plans {
|
|
|
|
|
guard case .fencedCodeFence(let markerRange, let languageRange) = plan.kind else {
|
|
|
|
|
if openRange != nil {
|
|
|
|
|
openLineIndexes.append(plan.line.index)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let sourceRange = openRange {
|
|
|
|
|
openLineIndexes.append(plan.line.index)
|
|
|
|
|
elements.append(.codeBlock(RenderedCodeBlockElement(
|
|
|
|
|
lineIndexes: openLineIndexes,
|
|
|
|
|
sourceRange: NSRange(
|
|
|
|
|
location: sourceRange.location,
|
|
|
|
|
length: plan.line.range.upperBound - sourceRange.location
|
|
|
|
|
),
|
|
|
|
|
languageRange: openLanguageRange
|
|
|
|
|
)))
|
|
|
|
|
openLineIndexes.removeAll()
|
|
|
|
|
openRange = nil
|
|
|
|
|
openLanguageRange = nil
|
|
|
|
|
} else {
|
|
|
|
|
openLineIndexes = [plan.line.index]
|
|
|
|
|
openRange = markerRange
|
|
|
|
|
openLanguageRange = languageRange
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let sourceRange = openRange,
|
|
|
|
|
let lastLine = plans.last?.line {
|
|
|
|
|
elements.append(.codeBlock(RenderedCodeBlockElement(
|
|
|
|
|
lineIndexes: openLineIndexes,
|
|
|
|
|
sourceRange: NSRange(
|
|
|
|
|
location: sourceRange.location,
|
|
|
|
|
length: lastLine.range.upperBound - sourceRange.location
|
|
|
|
|
),
|
|
|
|
|
languageRange: openLanguageRange
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return elements
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension NSRange {
|
|
|
|
|
var upperBound: Int {
|
|
|
|
|
location + length
|
|
|
|
|
}
|
|
|
|
|
}
|