tree-sitter highlighting in fenced code blocks within markdown

This commit is contained in:
jess 2026-04-06 13:53:45 -07:00
parent 5342ddbe5f
commit 0466d7ca56
1 changed files with 68 additions and 52 deletions

View File

@ -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)