From 0466d7ca569f147989f002b3648159ac3f869dae Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 6 Apr 2026 13:53:45 -0700 Subject: [PATCH] tree-sitter highlighting in fenced code blocks within markdown --- src/EditorView.swift | 120 ++++++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 9c9e37f..8b0cb0c 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -1641,56 +1641,63 @@ private func applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) } -private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { +private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, offset: Int = 0) { let palette = Theme.current let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - let nsText = textStorage.string as NSString - let textLen = nsText.length + let textLen = (textStorage.string as NSString).length + + // When offset > 0, spans are byte-relative to a substring starting at `offset` chars. + // Extract that substring and convert bytes to chars within it. + let sourceStr: String + if offset > 0 { + sourceStr = (textStorage.string as NSString).substring(from: offset) + } else { + sourceStr = textStorage.string + } + let sourceBytes = Array(sourceStr.utf8) for span in spans { - guard span.start < textLen && span.end <= textLen && span.start < span.end else { continue } + guard span.start < sourceBytes.count && span.end <= sourceBytes.count && span.start < span.end else { continue } - let byteStart = span.start - let byteEnd = span.end - let str = textStorage.string - let bytes = str.utf8 - let startIdx = bytes.index(bytes.startIndex, offsetBy: byteStart, limitedBy: bytes.endIndex) ?? bytes.endIndex - let endIdx = bytes.index(bytes.startIndex, offsetBy: byteEnd, limitedBy: bytes.endIndex) ?? bytes.endIndex - let charStart = str.distance(from: str.startIndex, to: String.Index(startIdx, within: str) ?? str.endIndex) - let charEnd = str.distance(from: str.startIndex, to: String.Index(endIdx, within: str) ?? str.endIndex) + // Convert byte offsets to character offsets within sourceStr + let prefix = sourceStr.utf8.prefix(span.start) + let charStart = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: prefix.count)) + let endPrefix = sourceStr.utf8.prefix(span.end) + let charEnd = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: endPrefix.count)) - guard charStart < textLen && charEnd <= textLen && charStart < charEnd else { continue } - let range = NSRange(location: charStart, length: charEnd - charStart) + let absStart = offset + charStart + let absEnd = offset + charEnd + guard absStart < textLen && absEnd <= textLen && absStart < absEnd else { continue } + let range = NSRange(location: absStart, length: absEnd - absStart) let color: NSColor var font: NSFont? = nil - // kind indices match HIGHLIGHT_NAMES in highlight.rs switch span.kind { - case 0: color = syn.keyword // keyword - case 1: color = syn.function // function - case 2: color = syn.function // function.builtin - case 3: color = syn.type // type - case 4: color = syn.type // type.builtin - case 5: color = syn.type // constructor - case 6: color = palette.peach // constant - case 7: color = palette.peach // constant.builtin - case 8: color = syn.string // string - case 9: color = syn.number // number - case 10: color = syn.comment; font = italicFont // comment - case 11: color = palette.text // variable - case 12: color = palette.red // variable.builtin - case 13: color = palette.maroon // variable.parameter - case 14: color = syn.operator // operator - case 15: color = palette.overlay2 // punctuation - case 16: color = palette.overlay2 // punctuation.bracket - case 17: color = palette.overlay2 // punctuation.delimiter - case 18: color = palette.teal // property - case 19: color = palette.red // tag (HTML) - case 20: color = palette.yellow // attribute - case 21: color = palette.sapphire // label - case 22: color = palette.pink // escape - case 23: color = palette.teal // embedded + case 0: color = syn.keyword + case 1: color = syn.function + case 2: color = syn.function + case 3: color = syn.type + case 4: color = syn.type + case 5: color = syn.type + case 6: color = palette.peach + case 7: color = palette.peach + case 8: color = syn.string + case 9: color = syn.number + case 10: color = syn.comment; font = italicFont + case 11: color = palette.text + case 12: color = palette.red + case 13: color = palette.maroon + case 14: color = syn.operator + case 15: color = palette.overlay2 + case 16: color = palette.overlay2 + case 17: color = palette.overlay2 + case 18: color = palette.teal + case 19: color = palette.red + case 20: color = palette.yellow + case 21: color = palette.sapphire + case 22: color = palette.pink + case 23: color = palette.teal default: continue } @@ -2004,9 +2011,12 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp let text = textStorage.string let nsText = text as NSString let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) + let syn = Theme.syntax var fencedRanges: [NSRange] = [] var lineStart = 0 var openFence: Int? = nil + var fenceLang: String? = nil + var codeStart: Int = 0 while lineStart < nsText.length { let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) @@ -2016,29 +2026,35 @@ private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: Catp if openFence == nil { if trimmed.hasPrefix("```") { openFence = lineRange.location - // Mute the fence line + codeStart = NSMaxRange(lineRange) + let langId = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + fenceLang = langId.isEmpty ? nil : langId.lowercased() textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) - // Language identifier after ``` - if trimmed.count > 3 { - let langStart = (nsText as NSString).range(of: "```", range: lineRange) - if langStart.location != NSNotFound { - let after = langStart.location + langStart.length - let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after) - if langRange.length > 0 { - textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange) - } - } - } } } else { if trimmed == "```" { - // Close fence textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) fencedRanges.append(blockRange) + + // Apply tree-sitter highlighting to the code content + let codeEnd = lineRange.location + if let lang = fenceLang, codeEnd > codeStart { + let codeRange = NSRange(location: codeStart, length: codeEnd - codeStart) + let code = nsText.substring(with: codeRange) + textStorage.addAttribute(.font, value: monoFont, range: codeRange) + let spans = RustBridge.shared.highlight(source: code, lang: lang) + if !spans.isEmpty { + applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: monoFont, offset: codeStart) + } else { + textStorage.addAttribute(.foregroundColor, value: palette.text, range: codeRange) + } + } + openFence = nil + fenceLang = nil } else { textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange)