import AppKit import Foundation // MARK: - MarkdownHighlighter /// The Edit editor's **lightweight Markdown syntax highlighting** (05-card-window.md ▸ Edit: /// "headings emphasized, bold/italic rendered as such, code tinted, link targets and structural /// markers dimmed"). /// /// ### It emits ranges, and that is the whole safety argument /// /// "Highlighting is presentation only: the text is the raw Markdown, character for character — no /// hidden transforms, no smart substitutions" (05 ▸ Edit). A highlighter that returned a string /// could break that promise; one that returns `[Span]` — offsets into the text it was handed — /// structurally cannot. `apply(_:to:pointSize:)` is the only part that touches a text storage, and it /// calls nothing but `setAttributes`/`addAttribute`. /// /// ### Why a line scanner rather than a swift-markdown re-parse /// /// Preview parses with swift-markdown (`BodyMarkup`) because it renders *structure* — tables, nested /// quotes, list nesting — and structure is what a parser is for. The editor needs something /// different, and the difference is decisive: /// /// - **It runs on every keystroke.** A full CommonMark parse per character, on the main actor, /// buys a document tree that is thrown away immediately; a single pass of a handful of /// line-anchored regexes is what the job actually needs. /// - **The text is usually invalid.** Half the time an editor's buffer holds `**bo` or `[label](`, /// because the user is mid-word. A parser resolves those to *paragraph text*, so emphasis would /// pop into existence on the closing asterisk and structure would flicker with every keystroke. /// A scanner highlights what is there: the delimiter dims as it is typed, and the run styles when /// it closes. /// - **Delimiters are the point here.** 05 asks for the markers themselves to be dimmed, and a /// parsed tree deliberately discards them — swift-markdown gives the emphasized *content*, not the /// asterisks around it. /// /// The cost is that the scanner is line-local: it knows fenced code blocks (a running state), and /// nothing else spanning lines. A `**bold` opened on one line and closed on the next is not styled, /// which is a fair trade for highlighting that never lies about half-typed markup and never re-parses /// a document to draw one line of it. /// /// ### Scope of a pass /// /// A pass rebuilds the whole body's attributes. That is honest for the input this app has — a card /// body is a card, not a book — and it is what keeps the fenced-code state correct without tracking /// which line invalidated which: the state is recomputed from the top, every time, in one linear /// walk over the text. enum MarkdownHighlighter { // MARK: - Vocabulary /// What a run of characters *is* — the five things 05 names, plus the structural markers it asks /// to have dimmed. enum Token: Equatable, Sendable { /// A heading's text (`# ` already excluded — that is `.structural`). case heading(level: Int) /// `**bold**`'s content. case strong /// `*italic*`'s content. case emphasis /// `~~struck~~`'s content. case strikethrough /// An inline code span's content, or a fenced/indented code line. case code /// A list's bullet, number, or task checkbox — the marker itself. case listMarker /// A link or image's visible text. case linkText /// A link or image's target — "link targets … dimmed" (05 ▸ Edit). case linkTarget /// Every delimiter: `#`, `**`, backticks, brackets, parens, `>`, a thematic break. case structural } /// One run of text and what it is. `range` is in **UTF-16 units** (`NSRange`), because its only /// consumer is `NSTextStorage` — the same reason `BodyMarkup` uses UTF-8 byte offsets and this /// does not: each carries the offsets its own consumer speaks. struct Span: Equatable, Sendable { var range: NSRange var token: Token } // MARK: - The scan /// Every styled run in `text`, in ascending order and never overlapping. /// /// Pure, total, and allocation-light: any string is valid input, including one that is malformed /// Markdown in every way at once, and the result is always a partition-compatible set of ranges /// inside `text`. static func spans(in text: String) -> [Span] { let ns = text as NSString guard ns.length > 0 else { return [] } var spans: [Span] = [] var fence: String? forEachLine(in: ns) { line in if let open = fence { // Inside a fenced block every line is code, and only the matching fence closes it. if let closing = fenceRun(in: ns, line: line), closing.marker == open { spans.append(Span(range: closing.range, token: .structural)) fence = nil } else { spans.append(Span(range: line, token: .code)) } return } if let opening = fenceRun(in: ns, line: line) { spans.append(Span(range: opening.range, token: .structural)) // The info string (` ```swift `) is part of the fence, not of the code. let info = NSRange( location: opening.range.upperBound, length: line.upperBound - opening.range.upperBound ) if info.length > 0 { spans.append(Span(range: info, token: .linkTarget)) } fence = opening.marker return } scanLine(line, in: ns, into: &spans) } return spans } /// One line, outside any fence. Block markers first — they decide what the rest of the line even /// is — then the inline pass over whatever is left. private static func scanLine(_ line: NSRange, in ns: NSString, into spans: inout [Span]) { // An indented code block: four spaces (or a tab) with content behind them. if firstMatch(Patterns.indentedCode, in: ns, range: line) != nil { spans.append(Span(range: line, token: .code)) return } if let rule = firstMatch(Patterns.thematicBreak, in: ns, range: line) { spans.append(Span(range: rule.range, token: .structural)) return } var content = line if let heading = firstMatch(Patterns.heading, in: ns, range: content) { let hashes = heading.range(at: 1) spans.append(Span(range: hashes, token: .structural)) let level = hashes.length let rest = NSRange(location: hashes.upperBound, length: content.upperBound - hashes.upperBound) if rest.length > 0 { spans.append(Span(range: rest, token: .heading(level: level))) } // **A heading's text takes no inline pass.** It is already emphasized, and layering a // body-sized bold run inside a larger heading font would make `# A **bold** title` read // as a heading with a hole in it. Dimming the `#` and emphasizing the rest is the whole // of what 05 asks for here. return } if let quote = firstMatch(Patterns.blockQuote, in: ns, range: content) { spans.append(Span(range: quote.range(at: 1), token: .structural)) content = NSRange( location: quote.range.upperBound, length: content.upperBound - quote.range.upperBound ) } if let item = firstMatch(Patterns.listItem, in: ns, range: content) { spans.append(Span(range: item.range(at: 2), token: .listMarker)) var after = NSRange( location: item.range.upperBound, length: content.upperBound - item.range.upperBound ) // A task checkbox is part of the marker, not of the text: `- [x] done`. if let box = firstMatch(Patterns.taskBox, in: ns, range: after) { spans.append(Span(range: box.range, token: .listMarker)) after = NSRange(location: box.range.upperBound, length: after.upperBound - box.range.upperBound) } content = after } scanInlines(content, in: ns, into: &spans) } /// The inline pass, in precedence order — a code span wins over everything inside it, a link's /// target is never emphasis, and `**` is tried before `*` so bold does not read as two italics. /// /// Claiming is by intersection against what earlier passes already took, which is what makes the /// order meaningful and the output non-overlapping. private static func scanInlines(_ range: NSRange, in ns: NSString, into spans: inout [Span]) { guard range.length > 0 else { return } var claimed: [NSRange] = [] func claim(_ match: NSTextCheckingResult, emit: (NSTextCheckingResult) -> [Span]) { guard !claimed.contains(where: { NSIntersectionRange($0, match.range).length > 0 }) else { return } claimed.append(match.range) spans.append(contentsOf: emit(match)) } for match in matches(Patterns.codeSpan, in: ns, range: range) { claim(match) { match in [ Span(range: match.range(at: 1), token: .structural), Span(range: match.range(at: 2), token: .code), Span(range: match.range(at: 3), token: .structural) ] } } for match in matches(Patterns.link, in: ns, range: range) { claim(match) { match in var emitted: [Span] = [] // The `!` of an image, the brackets and the parens: all dimmed structure. let openText = NSRange(location: match.range.location, length: match.range(at: 1).location - match.range.location) if openText.length > 0 { emitted.append(Span(range: openText, token: .structural)) } emitted.append(Span(range: match.range(at: 1), token: .linkText)) let between = NSRange( location: match.range(at: 1).upperBound, length: match.range(at: 2).location - match.range(at: 1).upperBound ) if between.length > 0 { emitted.append(Span(range: between, token: .structural)) } emitted.append(Span(range: match.range(at: 2), token: .linkTarget)) let close = NSRange( location: match.range(at: 2).upperBound, length: match.range.upperBound - match.range(at: 2).upperBound ) if close.length > 0 { emitted.append(Span(range: close, token: .structural)) } return emitted } } for match in matches(Patterns.autolink, in: ns, range: range) { claim(match) { match in [Span(range: match.range, token: .linkTarget)] } } for (pattern, token) in [ (Patterns.strong, Token.strong), (Patterns.strikethrough, Token.strikethrough), (Patterns.emphasis, Token.emphasis) ] { for match in matches(pattern, in: ns, range: range) { claim(match) { match in [ Span(range: match.range(at: 1), token: .structural), Span(range: match.range(at: 2), token: token), Span(range: match.range(at: 3), token: .structural) ] } } } spans.sort { $0.range.location < $1.range.location } } // MARK: - Application /// Lays the pass over a text storage: base attributes everywhere, then each span's own on top. /// /// **The only mutation is attributes.** `setAttributes` resets the whole body to the base run so /// deleted markup cannot leave its styling behind, and `addAttribute` layers each span — no /// character is inserted, removed, or replaced, which is 05's "presentation only" enforced by /// what this function is able to call. /// /// Wrapped in `beginEditing`/`endEditing` so the layout manager relays once for the whole pass /// rather than once per span. @MainActor static func apply(_ spans: [Span], to storage: NSTextStorage, pointSize: CGFloat) { let full = NSRange(location: 0, length: storage.length) storage.beginEditing() storage.setAttributes(baseAttributes(pointSize: pointSize), range: full) for span in spans { let range = NSIntersectionRange(span.range, full) guard range.length > 0 else { continue } for (key, value) in attributes(for: span.token, pointSize: pointSize) { storage.addAttribute(key, value: value, range: range) } } storage.endEditing() } /// Highlights `storage`'s current string in place — the editor's per-keystroke call. @MainActor static func highlight(_ storage: NSTextStorage, pointSize: CGFloat) { apply(spans(in: storage.string), to: storage, pointSize: pointSize) } /// The unstyled run: monospaced, at the body size, in the label colour. Also the editor's /// `typingAttributes`, so a character typed at the end of a styled run starts out plain and the /// next pass — one keystroke later — decides what it really is. @MainActor static func baseAttributes(pointSize: CGFloat) -> [NSAttributedString.Key: Any] { [ .font: monospaced(pointSize, weight: .regular), .foregroundColor: NSColor.labelColor ] } /// One token's presentation. Deliberately restrained — this is an editor, not a preview: the type /// stays monospaced throughout so columns line up, and the differences are weight, slant and /// colour. @MainActor static func attributes(for token: Token, pointSize: CGFloat) -> [NSAttributedString.Key: Any] { switch token { case let .heading(level): // Emphasized, and larger for the top two levels only — enough to read as a heading in a // monospaced grid without turning the editor into a preview. let scale: CGFloat = level <= 1 ? 1.25 : (level == 2 ? 1.12 : 1.0) return [ .font: monospaced((pointSize * scale).rounded(), weight: .bold), .foregroundColor: NSColor.labelColor ] case .strong: return [.font: monospaced(pointSize, weight: .bold)] case .emphasis: return [.font: italic(monospaced(pointSize, weight: .regular))] case .strikethrough: return [ .strikethroughStyle: NSUnderlineStyle.single.rawValue, .foregroundColor: NSColor.secondaryLabelColor ] case .code: // Tinted rather than boxed: a background behind every code line in an editor makes the // caret hard to find. return [.foregroundColor: NSColor.systemTeal] case .listMarker: return [.foregroundColor: NSColor.controlAccentColor] case .linkText: return [.foregroundColor: NSColor.linkColor] case .linkTarget: return [.foregroundColor: NSColor.tertiaryLabelColor] case .structural: return [.foregroundColor: NSColor.tertiaryLabelColor] } } @MainActor private static func monospaced(_ size: CGFloat, weight: NSFont.Weight) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: weight) } @MainActor private static func italic(_ font: NSFont) -> NSFont { NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) } // MARK: - Line walking /// Every line's range, newline **excluded** — a line's terminator is not part of anything it /// carries, and including it would let a heading's colour bleed onto the next line's start in a /// wrapped layout. /// /// **`location < length`, strictly.** A position *at* the end of a text that does not end in a /// newline is still inside the last line, so `lineRange(for:)` answers with that line's range — /// which starts before the position asked about. Walking to `<=` therefore re-visits the last /// line forever on any text whose final line is unterminated, which in an editor is every text /// the user is in the middle of typing. The `upperBound > location` guard below is the same /// promise made twice: the walk advances or it stops. private static func forEachLine(in ns: NSString, _ visit: (NSRange) -> Void) { var location = 0 while location < ns.length { let line = ns.lineRange(for: NSRange(location: location, length: 0)) var content = line // Strip the terminator (`\n`, `\r\n`, `\r`, or a Unicode line separator). while content.length > 0 { let last = ns.character(at: content.upperBound - 1) guard last == 0x0A || last == 0x0D || last == 0x2028 || last == 0x2029 else { break } content.length -= 1 } if content.length > 0 { visit(content) } guard line.upperBound > location else { return } location = line.upperBound } } /// A line's opening or closing code fence, if it has one: the run of backticks or tildes, and /// which of the two it is (a ``` block is not closed by a ~~~ line). private static func fenceRun(in ns: NSString, line: NSRange) -> (range: NSRange, marker: String)? { guard let match = firstMatch(Patterns.fence, in: ns, range: line) else { return nil } let run = match.range(at: 1) return (range: match.range, marker: ns.substring(with: NSRange(location: run.location, length: 1))) } // MARK: - Regex plumbing private static func matches(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> [NSTextCheckingResult] { pattern.matches(in: ns as String, options: [], range: range) } private static func firstMatch(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> NSTextCheckingResult? { pattern.firstMatch(in: ns as String, options: [], range: range) } /// Compiled once. Each is anchored the way its construct is anchored in Markdown — block /// patterns at the start of a line, inline patterns anywhere in it. /// /// `try!` is load-bearing rather than lazy: these are literals, so a failure here is a typo that /// would fail on the first launch of a debug build, not a runtime condition a user can reach. private enum Patterns { static let heading = regex("^ {0,3}(#{1,6})(?:[ \t]|$)") static let thematicBreak = regex("^ {0,3}(?:(?:\\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$") static let blockQuote = regex("^[ \t]*(>+)[ \t]?") static let listItem = regex("^([ \t]*)([-*+]|\\d{1,9}[.)])(?=[ \t])") static let taskBox = regex("^[ \t]*\\[[ xX]\\]") static let fence = regex("^ {0,3}(`{3,}|~{3,})") static let indentedCode = regex("^(?: {4}|\t)[ \t]*\\S") static let codeSpan = regex("(`+)([^`]*)(\\1)") static let link = regex("!?\\[([^\\]\\n]*)\\]\\(([^)\\n]*)\\)") static let autolink = regex("<(?:https?|mailto|file):[^>\\s]*>") static let strong = regex("(\\*\\*|__)((?:(?!\\1).)+)(\\1)") static let emphasis = regex("(? NSRegularExpression { // swiftlint:disable:next force_try try! NSRegularExpression(pattern: pattern, options: []) } } }