diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 4adffd5..3ccd70a 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -666,6 +666,9 @@ enum MarkdownTextStyler { let lines = invalidationPlan.isFullRender ? lineIndex.editorLines(activeLineIndex: activeLineIndex) : lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex) + let renderPlansByLine = Dictionary( + uniqueKeysWithValues: renderer.renderPlans(for: lines).map { ($0.line.index, $0) } + ) var styledLineCount = 0 for line in lines { resetAttributes(in: textStorage, line: line, textColor: textColor) @@ -681,7 +684,9 @@ enum MarkdownTextStyler { styleRenderedLine( in: textStorage, line: line, - renderPlan: renderer.renderPlan(for: line), + renderPlan: renderPlansByLine[line.index] ?? renderer.renderPlan(for: line), + textColor: textColor, + backgroundColor: backgroundColor, secondaryTextColor: secondaryTextColor, accentColor: accentColor ) @@ -705,19 +710,108 @@ enum MarkdownTextStyler { in textStorage: NSTextStorage, line: EditorLine, renderPlan: HybridMarkdownLineRenderPlan, + textColor: PlatformColor, + backgroundColor: PlatformColor, secondaryTextColor: PlatformColor, accentColor: PlatformColor ) { guard line.range.length > 0 else { return } - if case .heading(let level, let markerRange, let textRange) = renderPlan.kind { + switch renderPlan.kind { + case .heading(let level, let markerRange, let textRange): textStorage.addAttributes([ .foregroundColor: secondaryTextColor, .font: monospacedFont(size: 13, weight: .regular) ], range: markerRange) textStorage.addAttributes([ - .font: systemFont(size: headingFontSize(level: level), weight: .semibold) + .font: systemFont(size: headingFontSize(level: level), weight: .semibold), + .paragraphStyle: headingParagraphStyle(level: level) ], range: textRange) + case .blockquote(let markerRange, let contentRange): + textStorage.addAttributes([ + .foregroundColor: accentColor, + .font: monospacedFont(size: 15, weight: .semibold) + ], range: markerRange) + textStorage.addAttributes([ + .foregroundColor: textColor, + .backgroundColor: accentColor.withAlphaComponent(0.08), + .paragraphStyle: blockquoteParagraphStyle() + ], range: line.range) + textStorage.addAttributes([ + .font: italicSystemFont(size: 16) + ], range: contentRange) + case .horizontalRule(let range): + textStorage.addAttributes([ + .foregroundColor: secondaryTextColor, + .strikethroughStyle: NSUnderlineStyle.thick.rawValue, + .paragraphStyle: horizontalRuleParagraphStyle() + ], range: range) + case .unorderedList(let markerRange, let contentRange, let nestingLevel): + styleListLine( + in: textStorage, + lineRange: line.range, + markerRange: markerRange, + contentRange: contentRange, + nestingLevel: nestingLevel, + secondaryTextColor: secondaryTextColor + ) + case .orderedList(let markerRange, let contentRange, let nestingLevel): + styleListLine( + in: textStorage, + lineRange: line.range, + markerRange: markerRange, + contentRange: contentRange, + nestingLevel: nestingLevel, + secondaryTextColor: secondaryTextColor + ) + case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel): + styleListLine( + in: textStorage, + lineRange: line.range, + markerRange: markerRange, + contentRange: contentRange, + nestingLevel: nestingLevel, + secondaryTextColor: secondaryTextColor + ) + textStorage.addAttributes([ + .foregroundColor: checked ? accentColor : secondaryTextColor, + .font: monospacedFont(size: 15, weight: .semibold), + .backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor + ], range: checkboxRange) + if checked { + textStorage.addAttributes([ + .strikethroughStyle: NSUnderlineStyle.single.rawValue, + .foregroundColor: secondaryTextColor + ], range: contentRange) + } + case .fencedCodeFence(let markerRange, let languageRange): + textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) + textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: markerRange) + if let languageRange { + textStorage.addAttributes([ + .foregroundColor: accentColor, + .font: monospacedFont(size: 13, weight: .semibold) + ], range: languageRange) + } + case .codeBlockContent: + textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) + case .tableRow(_, let separatorRanges, let isDivider): + textStorage.addAttributes([ + .font: monospacedFont(size: 15, weight: .regular), + .backgroundColor: accentColor.withAlphaComponent(0.06), + .paragraphStyle: tableParagraphStyle() + ], range: line.range) + for separatorRange in separatorRanges { + textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange) + } + if isDivider { + textStorage.addAttributes([ + .foregroundColor: secondaryTextColor, + .font: monospacedFont(size: 15, weight: .semibold) + ], range: line.range) + } + case .paragraph: + break } styleInlineSpans( @@ -745,6 +839,11 @@ enum MarkdownTextStyler { .font: monospacedFont(size: 15, weight: .regular), .backgroundColor: accentColor.withAlphaComponent(0.12) ], range: span.range) + case .link, .automaticLink: + textStorage.addAttributes([ + .foregroundColor: accentColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ], range: span.range) case .markdownDelimiter: textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range) } @@ -756,7 +855,7 @@ enum MarkdownTextStyler { line: EditorLine, secondaryTextColor: PlatformColor ) { - applyRegex("(#{1,6}|\\*\\*|\\*|`)", in: textStorage, line: line) { match in + applyRegex("(#{1,6}|\\*\\*|__|\\*|_|`|\\[[ xX]\\]|\\[|\\]|\\(|\\)|\\||>|-{3,}|[-*]|\\d+[.)])", in: textStorage, line: line) { match in textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range) } } @@ -794,6 +893,83 @@ enum MarkdownTextStyler { } } + private static func headingParagraphStyle(level: Int) -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 4 + paragraph.paragraphSpacingBefore = level <= 2 ? 10 : 6 + paragraph.paragraphSpacing = level <= 2 ? 9 : 6 + return paragraph + } + + private static func blockquoteParagraphStyle() -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 4 + paragraph.paragraphSpacing = 6 + paragraph.headIndent = 18 + paragraph.firstLineHeadIndent = 0 + return paragraph + } + + private static func horizontalRuleParagraphStyle() -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 2 + paragraph.paragraphSpacingBefore = 10 + paragraph.paragraphSpacing = 10 + return paragraph + } + + private static func listParagraphStyle(nestingLevel: Int) -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + let indent = CGFloat(20 + nestingLevel * 18) + paragraph.lineSpacing = 4 + paragraph.paragraphSpacing = 4 + paragraph.firstLineHeadIndent = 0 + paragraph.headIndent = indent + return paragraph + } + + private static func tableParagraphStyle() -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 3 + paragraph.paragraphSpacing = 2 + return paragraph + } + + private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 3 + paragraph.paragraphSpacing = 2 + return paragraph + } + + private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] { + [ + .font: monospacedFont(size: 15, weight: .regular), + .backgroundColor: accentColor.withAlphaComponent(0.08), + .paragraphStyle: codeBlockParagraphStyle() + ] + } + + private static func styleListLine( + in textStorage: NSTextStorage, + lineRange: NSRange, + markerRange: NSRange, + contentRange: NSRange, + nestingLevel: Int, + secondaryTextColor: PlatformColor + ) { + textStorage.addAttributes([ + .paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel) + ], range: lineRange) + textStorage.addAttributes([ + .foregroundColor: secondaryTextColor, + .font: monospacedFont(size: 15, weight: .semibold) + ], range: markerRange) + textStorage.addAttributes([ + .font: systemFont(size: 16, weight: .regular) + ], range: contentRange) + } + #if os(macOS) private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont { NSFont.systemFont(ofSize: size, weight: weight) diff --git a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift index 064aaf4..0ad4d2c 100644 --- a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift +++ b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift @@ -3,12 +3,28 @@ import Foundation public enum HybridMarkdownLineKind: Hashable, Sendable { case paragraph case heading(level: Int, markerRange: NSRange, textRange: NSRange) + case blockquote(markerRange: NSRange, contentRange: NSRange) + case horizontalRule(range: NSRange) + case unorderedList(markerRange: NSRange, contentRange: NSRange, nestingLevel: Int) + case orderedList(markerRange: NSRange, contentRange: NSRange, nestingLevel: Int) + case taskList( + markerRange: NSRange, + checkboxRange: NSRange, + contentRange: NSRange, + checked: Bool, + nestingLevel: Int + ) + case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?) + case codeBlockContent + case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool) } public enum HybridMarkdownSpanKind: Hashable, Sendable { case bold case italic case inlineCode + case link + case automaticLink case markdownDelimiter } @@ -39,22 +55,89 @@ public struct HybridMarkdownLineRenderer: Sendable { public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan { let kind = lineKind(for: line) - let spans = inlineSpans(in: line) + let spans = inlineSpans(in: line, kind: kind) 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 + public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] { + var isInCodeBlock = false + var plans: [HybridMarkdownLineRenderPlan] = [] + + for line in lines { + let nonCodeKind = lineKind(for: line) + let kind: HybridMarkdownLineKind + + if case .fencedCodeFence = nonCodeKind { + kind = nonCodeKind + isInCodeBlock.toggle() + } else if isInCodeBlock { + kind = .codeBlockContent + } else { + kind = nonCodeKind + } + + plans.append(HybridMarkdownLineRenderPlan( + line: line, + kind: kind, + spans: inlineSpans(in: line, kind: kind) + )) } - let textOffset = markerCount + 1 + return plans + } + + private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind { + if let fence = fencedCodeFence(in: line) { + return fence + } + + if isHorizontalRule(line.source) { + return .horizontalRule(range: line.range) + } + + if let heading = heading(in: line) { + return heading + } + + if let task = taskList(in: line) { + return task + } + + if let unorderedList = unorderedList(in: line) { + return unorderedList + } + + if let orderedList = orderedList(in: line) { + return orderedList + } + + if let blockquote = blockquote(in: line) { + return blockquote + } + + if let table = tableRow(in: line) { + return table + } + + return .paragraph + } + + private func heading(in line: EditorLine) -> HybridMarkdownLineKind? { + let leadingWhitespace = line.source.prefix { $0 == " " }.count + guard leadingWhitespace <= 3 else { return nil } + + let headingStart = line.source.dropFirst(leadingWhitespace) + let markerCount = headingStart.prefix { $0 == "#" }.count + guard (1...6).contains(markerCount), + headingStart.dropFirst(markerCount).first == " " + else { + return nil + } + + let textOffset = leadingWhitespace + markerCount + 1 return .heading( level: markerCount, - markerRange: NSRange(location: line.range.location, length: markerCount), + markerRange: NSRange(location: line.range.location + leadingWhitespace, length: markerCount), textRange: NSRange( location: line.range.location + textOffset, length: max(0, line.range.length - textOffset) @@ -62,8 +145,129 @@ public struct HybridMarkdownLineRenderer: Sendable { ) } - private func inlineSpans(in line: EditorLine) -> [HybridMarkdownSpan] { + private func blockquote(in line: EditorLine) -> HybridMarkdownLineKind? { + let trimmedPrefix = line.source.prefix { $0 == " " } + guard trimmedPrefix.count <= 3 else { return nil } + let start = trimmedPrefix.count + guard line.source.dropFirst(start).first == ">" else { return nil } + + var markerLength = start + var cursor = line.source.index(line.source.startIndex, offsetBy: start) + while cursor < line.source.endIndex, line.source[cursor] == ">" { + markerLength += 1 + cursor = line.source.index(after: cursor) + } + if cursor < line.source.endIndex, line.source[cursor] == " " { + markerLength += 1 + } + + return .blockquote( + markerRange: NSRange(location: line.range.location, length: markerLength), + contentRange: NSRange( + location: line.range.location + markerLength, + length: max(0, line.range.length - markerLength) + ) + ) + } + + private func unorderedList(in line: EditorLine) -> HybridMarkdownLineKind? { + guard let match = firstMatch("^([ \\t]*)([-*])\\s+(.*)$", in: line.source), + match.range(at: 3).length > 0 + else { return nil } + + let indentation = match.range(at: 1).length + let markerRange = NSRange( + location: line.range.location + match.range(at: 2).location, + length: match.range(at: 2).length + ) + let contentRange = NSRange( + location: line.range.location + match.range(at: 3).location, + length: match.range(at: 3).length + ) + return .unorderedList(markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel(indentation)) + } + + private func orderedList(in line: EditorLine) -> HybridMarkdownLineKind? { + guard let match = firstMatch("^([ \\t]*)(\\d+[.)])\\s+(.*)$", in: line.source), + match.range(at: 3).length > 0 + else { return nil } + + let indentation = match.range(at: 1).length + let markerRange = NSRange( + location: line.range.location + match.range(at: 2).location, + length: match.range(at: 2).length + ) + let contentRange = NSRange( + location: line.range.location + match.range(at: 3).location, + length: match.range(at: 3).length + ) + return .orderedList(markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel(indentation)) + } + + private func taskList(in line: EditorLine) -> HybridMarkdownLineKind? { + guard let match = firstMatch("^([ \\t]*)([-*])\\s+(\\[[ xX]\\])\\s+(.*)$", in: line.source), + match.range(at: 4).length > 0 + else { return nil } + + let indentation = match.range(at: 1).length + let checkbox = (line.source as NSString).substring(with: match.range(at: 3)) + return .taskList( + markerRange: NSRange( + location: line.range.location + match.range(at: 2).location, + length: match.range(at: 2).length + ), + checkboxRange: NSRange( + location: line.range.location + match.range(at: 3).location, + length: match.range(at: 3).length + ), + contentRange: NSRange( + location: line.range.location + match.range(at: 4).location, + length: match.range(at: 4).length + ), + checked: checkbox.lowercased() == "[x]", + nestingLevel: nestingLevel(indentation) + ) + } + + private func fencedCodeFence(in line: EditorLine) -> HybridMarkdownLineKind? { + guard let match = firstMatch("^([ \\t]*)(```|~~~)([^`~]*)$", in: line.source) else { return nil } + let languageRange = match.range(at: 3).length > 0 + ? NSRange(location: line.range.location + match.range(at: 3).location, length: match.range(at: 3).length) + : nil + return .fencedCodeFence( + markerRange: NSRange( + location: line.range.location + match.range(at: 2).location, + length: match.range(at: 2).length + ), + languageRange: languageRange + ) + } + + private func tableRow(in line: EditorLine) -> HybridMarkdownLineKind? { + guard line.source.contains("|") else { return nil } + let trimmed = line.source.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|"), trimmed.count > 2 else { return nil } + + let separatorRanges = regexMatches("\\|", in: line.source).map { + NSRange(location: line.range.location + $0.range.location, length: $0.range.length) + } + guard separatorRanges.count >= 1 else { return nil } + + let cellRanges = tableCellRanges(in: line.source).map { + NSRange(location: line.range.location + $0.location, length: $0.length) + } + return .tableRow( + cellRanges: cellRanges, + separatorRanges: separatorRanges, + isDivider: isTableDivider(trimmed) + ) + } + + private func inlineSpans(in line: EditorLine, kind: HybridMarkdownLineKind) -> [HybridMarkdownSpan] { guard line.range.length > 0 else { return [] } + if case .horizontalRule = kind { return [] } + if case .fencedCodeFence = kind { return [] } + if case .codeBlockContent = kind { return [] } var spans: [HybridMarkdownSpan] = [] var excludedRanges: [NSRange] = [] @@ -80,12 +284,38 @@ public struct HybridMarkdownLineRenderer: Sendable { 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) + } + + collectLinkMatches(in: line, excluding: excludedRanges).forEach { match in + spans.append(HybridMarkdownSpan(range: match.titleRange, kind: .link)) + spans.append(HybridMarkdownSpan(range: match.urlRange, kind: .markdownDelimiter)) + spans.append(contentsOf: match.delimiterRanges.map { + HybridMarkdownSpan(range: $0, kind: .markdownDelimiter) + }) + excludedRanges.append(match.fullRange) + } + + collectMatches("(https?://[^\\s<>()]+)", in: line, excluding: excludedRanges).forEach { match in + spans.append(HybridMarkdownSpan(range: match.fullRange, kind: .automaticLink)) + excludedRanges.append(match.fullRange) + } + collectMatches("(? [LinkMatch] { + guard let regex = try? NSRegularExpression(pattern: "(? 2 else { return nil } + let fullRange = NSRange(location: line.range.location + match.range.location, length: match.range.length) + guard !excludedRanges.contains(where: { $0.intersects(fullRange) }) else { return nil } + let titleRange = NSRange( + location: line.range.location + match.range(at: 1).location, + length: match.range(at: 1).length + ) + let urlRange = NSRange( + location: line.range.location + match.range(at: 2).location, + length: match.range(at: 2).length + ) + return LinkMatch( + fullRange: fullRange, + titleRange: titleRange, + urlRange: urlRange, + delimiterRanges: [ + NSRange(location: fullRange.location, length: 1), + NSRange(location: titleRange.upperBound, length: 2), + NSRange(location: fullRange.upperBound - 1, length: 1) + ] + ) + } + } + private func delimiterRanges( _ fullRange: NSRange, leading: Int, @@ -131,6 +392,70 @@ public struct HybridMarkdownLineRenderer: Sendable { ) ] } + + private func firstMatch(_ pattern: String, in source: String) -> NSTextCheckingResult? { + regexMatches(pattern, in: source).first + } + + private func regexMatches(_ pattern: String, in source: String) -> [NSTextCheckingResult] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + return regex.matches(in: source, range: NSRange(location: 0, length: source.utf16.count)) + } + + private func nestingLevel(_ indentationLength: Int) -> Int { + max(0, indentationLength / 2) + } + + private func isHorizontalRule(_ source: String) -> Bool { + let trimmed = source.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 3 else { return false } + let compact = trimmed.filter { $0 != " " && $0 != "\t" } + guard compact.count >= 3, let first = compact.first, ["-", "*", "_"].contains(first) else { + return false + } + return compact.allSatisfy { $0 == first } + } + + private func isTableDivider(_ source: String) -> Bool { + let cells = source.split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + let core = cell.trimmingCharacters(in: CharacterSet(charactersIn: ":")) + return core.count >= 3 && core.allSatisfy { $0 == "-" } + } + } + + private func tableCellRanges(in source: String) -> [NSRange] { + let nsSource = source as NSString + var ranges: [NSRange] = [] + var cellStart = 0 + + for location in 0.. 0 } + } + + private func appendTrimmedRange(from start: Int, to end: Int, in source: NSString, ranges: inout [NSRange]) { + var lower = start + var upper = end + while lower < upper, isWhitespace(source.character(at: lower)) { + lower += 1 + } + while upper > lower, isWhitespace(source.character(at: upper - 1)) { + upper -= 1 + } + ranges.append(NSRange(location: lower, length: upper - lower)) + } + + private func isWhitespace(_ character: unichar) -> Bool { + character == 32 || character == 9 + } } private struct InlineMatch { @@ -138,6 +463,13 @@ private struct InlineMatch { var contentRange: NSRange } +private struct LinkMatch { + var fullRange: NSRange + var titleRange: NSRange + var urlRange: NSRange + var delimiterRanges: [NSRange] +} + private extension NSRange { var upperBound: Int { location + length diff --git a/Tests/SaplingEditorTests/EditorStateTests.swift b/Tests/SaplingEditorTests/EditorStateTests.swift index f714601..c153cf3 100644 --- a/Tests/SaplingEditorTests/EditorStateTests.swift +++ b/Tests/SaplingEditorTests/EditorStateTests.swift @@ -120,7 +120,7 @@ final class EditorStateTests: XCTestCase { ) } - func testHybridRendererDoesNotPromoteLinksOrTasksInMilestoneTwo() { + func testHybridRendererSupportsTaskListsAndLinks() { let source = "- [ ] Task with [link](https://example.com)" let line = EditorLine( index: 0, @@ -131,8 +131,117 @@ final class EditorStateTests: XCTestCase { let plan = HybridMarkdownLineRenderer().renderPlan(for: line) - XCTAssertEqual(plan.kind, .paragraph) - XCTAssertTrue(plan.spans.isEmpty) + XCTAssertEqual( + plan.kind, + .taskList( + markerRange: NSRange(location: 0, length: 1), + checkboxRange: NSRange(location: 2, length: 3), + contentRange: NSRange(location: 6, length: 37), + checked: false, + nestingLevel: 0 + ) + ) + XCTAssertTrue(plan.spans.contains { $0.kind == .link && (source as NSString).substring(with: $0.range) == "link" }) + XCTAssertEqual(plan.spans.filter { $0.kind == .markdownDelimiter }.count, 4) + } + + func testHybridRendererSupportsBlockquotesHorizontalRulesAndLists() { + let blockquoteSource = "> quoted **text**" + let blockquote = EditorLine( + index: 0, + source: blockquoteSource, + range: NSRange(location: 10, length: blockquoteSource.utf16.count), + mode: .rendered + ) + let horizontalRule = EditorLine( + index: 1, + source: "---", + range: NSRange(location: 28, length: 3), + mode: .rendered + ) + let nestedList = EditorLine( + index: 2, + source: " 1. nested item", + range: NSRange(location: 32, length: 16), + mode: .rendered + ) + + let renderer = HybridMarkdownLineRenderer() + + XCTAssertEqual( + renderer.renderPlan(for: blockquote).kind, + .blockquote( + markerRange: NSRange(location: 10, length: 2), + contentRange: NSRange(location: 12, length: 15) + ) + ) + XCTAssertEqual(renderer.renderPlan(for: horizontalRule).kind, .horizontalRule(range: horizontalRule.range)) + XCTAssertEqual( + renderer.renderPlan(for: nestedList).kind, + .orderedList( + markerRange: NSRange(location: 34, length: 2), + contentRange: NSRange(location: 37, length: 11), + nestingLevel: 1 + ) + ) + } + + func testHybridRendererSupportsAutomaticLinks() { + let source = "See https://example.com/docs for details" + let line = EditorLine( + index: 0, + source: source, + range: NSRange(location: 0, length: source.utf16.count), + mode: .rendered + ) + + let plan = HybridMarkdownLineRenderer().renderPlan(for: line) + + XCTAssertTrue(plan.spans.contains { + $0.kind == .automaticLink && (source as NSString).substring(with: $0.range) == "https://example.com/docs" + }) + } + + func testHybridRendererSupportsFencedCodeBlocksWithLanguage() { + let source = "```swift\nlet value = 42\n```" + let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99) + + let plans = HybridMarkdownLineRenderer().renderPlans(for: lines) + + XCTAssertEqual( + plans[0].kind, + .fencedCodeFence( + markerRange: NSRange(location: 0, length: 3), + languageRange: NSRange(location: 3, length: 5) + ) + ) + XCTAssertEqual(plans[1].kind, .codeBlockContent) + XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil)) + } + + func testHybridRendererSupportsMarkdownTables() { + let header = "| Name | Value |" + let divider = "| ---- | ----- |" + let row = "| A | B |" + let source = [header, divider, row].joined(separator: "\n") + let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99) + + let plans = HybridMarkdownLineRenderer().renderPlans(for: lines) + + guard case .tableRow(let headerCells, let headerSeparators, false) = plans[0].kind else { + return XCTFail("Expected header table row.") + } + guard case .tableRow(_, _, true) = plans[1].kind else { + return XCTFail("Expected divider table row.") + } + guard case .tableRow(let rowCells, _, false) = plans[2].kind else { + return XCTFail("Expected body table row.") + } + + XCTAssertEqual(headerCells.count, 2) + XCTAssertEqual(headerSeparators.count, 3) + XCTAssertEqual((source as NSString).substring(with: rowCells[0]), "A") + XCTAssertEqual((source as NSString).substring(with: rowCells[1]), "B") } @MainActor