diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index a22204ec..64f469d3 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -107,12 +107,34 @@ private var sampleMarkdown: String { [ markdownHeader, inlineFormattingSection, + tableSection, latexSection, codeSection, markdownFooter, ].joined(separator: "\n\n") } +/// Table layout demo: the first table's cells WRAP to the available width +/// (CSS auto-layout style); the second has so many columns that even the +/// longest-word minimums don't fit — it stays wide and scrolls horizontally. +private let tableSection = """ +## Tables + +Cells wrap to the available width: + +| Rechtsform | Gründungskosten | Laufende Kosten/Jahr | +|---|---|---| +| Einzelunternehmen (Kleingewerbe) | 20–60€ (Gewerbeanmeldung) | ~0€ (nur Steuerberater optional, 300–800€) | +| GbR (mit zwei Gesellschaftern) | 20–60€ x Anzahl Gesellschafter (jeder meldet einzeln an) | Gesellschaftervertrag empfohlen (Anwalt: 500–1.500€ einmalig) | +| UG (haftungsbeschränkt) | Notar + Handelsregister: ~300–500€ (Musterprotokoll) bis 1.000€+ | IHK-Beitrag (~150–400€), Steuerberater fast Pflicht | + +Too many columns → horizontal scroll instead of crushed cells: + +| Rechtsformvergleich | Gründungskostenaufstellung | Haftungsbeschränkung | Steuerberaterkosten | Handelsregistereintrag | Stammkapitalanforderung | +|---|---|---|---|---|---| +| Einzelunternehmen | Gewerbeanmeldung | unbeschränkt | optional | nein | keines | +""" + private let markdownHeader = """ # MarkdownEngine diff --git a/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift new file mode 100644 index 00000000..31dae22e --- /dev/null +++ b/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -0,0 +1,135 @@ +// +// PerfTrace.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// +// TEMP diagnostics (typing performance). Prints one compact line per keystroke +// with a per-phase breakdown plus the current document length, so we can see +// which costs grow with file size instead of staying constant. The whole point: +// type in a short file, then a long one, and compare `total` for the same edit. +// +// Toggle: set the env var MD_PERF=0 in the run scheme to silence. +// Debug-only — the whole thing compiles out in Release. +// Remove before shipping (this file + the `PerfTrace.` call sites). +// + +import Foundation + +enum PerfTrace { +#if DEBUG + static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" + /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, + /// backtick census, parse buffer). They run 3× O(doc) work synchronously + /// on every 64th keystroke — periodic spikes that pollute the PERF + /// numbers — so they stay off unless explicitly requested. + static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1" +#else + static let enabled = false + static let verifyEnabled = false +#endif + + // All call sites run on the main thread (the coordinator + text view are + // main-actor), so plain static state is safe under the package's Swift 5 mode. + private static var active = false + private static var frameStart: UInt64 = 0 + private static var docLength = 0 + private static var phases: [(String, Double)] = [] + private static var notes: [String] = [] + /// Summed costs for code that runs MANY times per frame or from inside + /// AppKit callbacks (caret reveal, spell-checker callbacks) — printed as + /// `+label=…(×n)` after the sequential phases. + private static var accumulated: [(String, Double, Int)] = [] + + private static func nowMs() -> Double { + Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000 + } + + /// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it. + /// A frame already opened this keystroke is CONTINUED, not reset: + /// shouldChangeTextIn opens the frame (so the pre-edit parse and the + /// smart-input interceptors are counted — they used to run before the + /// frame and were invisible), the mid-edit selection change and + /// textDidChange attach to it. A frame left open by an edit that never + /// reached textDidChange is considered stale after 1s and reset. + static func begin(docLength len: Int) { + guard enabled else { return } + let now = DispatchTime.now().uptimeNanoseconds + docLength = len + if active, Double(now - frameStart) / 1_000_000 < 1_000 { return } + active = true + phases.removeAll(keepingCapacity: true) + notes.removeAll(keepingCapacity: true) + accumulated.removeAll(keepingCapacity: true) + frameStart = now + } + + /// Like `measure`, but SUMS repeated calls under one label instead of + /// appending a phase per call — for work triggered from inside AppKit + /// (caret reveal, spell-checker callbacks) that can fire several times + /// per keystroke and would otherwise stay invisible in the frame. + @discardableResult + static func accumulate(_ label: String, _ body: () -> T) -> T { + guard enabled, active else { return body() } + let t0 = nowMs() + let result = body() + let dt = nowMs() - t0 + if let i = accumulated.firstIndex(where: { $0.0 == label }) { + accumulated[i].1 += dt + accumulated[i].2 += 1 + } else { + accumulated.append((label, dt, 1)) + } + return result + } + + /// Time one sequential top-level phase of the current frame. + @discardableResult + static func measure(_ label: String, _ body: () -> T) -> T { + guard enabled, active else { return body() } + let t0 = nowMs() + let result = body() + phases.append((label, nowMs() - t0)) + return result + } + + /// Attach a free-form detail line (e.g. how many tables were re-rendered). + /// The closure only runs when tracing is active, so it costs nothing when off. + static func note(_ make: () -> String) { + guard enabled, active else { return } + notes.append(make()) + } + + /// Record a named timestamp (offset from frame start) inline in the + /// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and + /// the measured spans locate work the spans don't cover (AppKit edit + /// application, layout, notification dispatch between our callbacks). + static func checkpoint(_ label: String) { + guard enabled, active else { return } + phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000)) + } + + /// Close the frame and print total + per-phase breakdown + notes. + /// `other` = total − Σ(phases + accumulated): time inside the frame that + /// no span covers (AppKit edit processing, layout, unmeasured code). + static func end() { + guard enabled, active else { return } + active = false + let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000 + var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ") + if !accumulated.isEmpty { + breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ") + } + let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 } + + accumulated.reduce(0) { $0 + $1.1 } + print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered)) + for note in notes { print(" └─ \(note)") } + } + + /// Standalone timing print for a cost that runs *outside* the keystroke frame + /// (e.g. the async wide-table overlay reconcile fired after the edit settles). + static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") { + guard enabled else { return } + print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail())) + } +} diff --git a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift index 9d16a74d..ce75af03 100644 --- a/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift @@ -11,8 +11,15 @@ import AppKit enum MarkdownInputHandler { - static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool { - return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + /// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing + /// parse) answers "is the caret in code?" without the O(doc) document + /// scan the handler otherwise runs on every space/Enter/Tab. + static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool { + let isInsideCodeBlock = codeTokens.map { + MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0) + } + return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange, + replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock) } // MARK: - Block LaTeX Auto-Wrap @@ -20,6 +27,10 @@ enum MarkdownInputHandler { private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) { if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = true + // Replaces a suppressed keystroke that never applied — reset its + // pending count so this edit registers as the cycle's single + // tracked edit and textDidChange keeps the trusted fast paths. + coord.pendingEditCount = 0 } textView.insertText(text, replacementRange: range) if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { diff --git a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift index 479e2086..80d2dc65 100644 --- a/Sources/MarkdownEngine/Input/MarkdownListHandler.swift +++ b/Sources/MarkdownEngine/Input/MarkdownListHandler.swift @@ -17,7 +17,15 @@ struct MarkdownLists { let len = min(range.length, max(0, maxLen)) let safeRange = NSRange(location: loc, length: len) - if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = true } + if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { + coord.isProgrammaticEdit = true + // This edit REPLACES a suppressed keystroke that never applied. + // Dropping its pending count lets the shouldChangeText below + // re-register as the cycle's single tracked edit, so textDidChange + // keeps the trusted fast paths (the descriptor is refreshed for + // every proposed edit and describes THIS transition exactly). + coord.pendingEditCount = 0 + } defer { if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator { coord.isProgrammaticEdit = false } } @@ -87,10 +95,14 @@ struct MarkdownLists { // MARK: - Input Handling - static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?) -> Bool { + /// `isInsideCodeBlock` is the caller's pre-parsed answer for + /// `affectedCharRange.location` (the coordinator derives it from the + /// keystroke's existing parse). `nil` — direct callers without a parse — + /// falls back to deriving it here, which walks the whole document. + static func handleInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, isInsideCodeBlock: Bool? = nil) -> Bool { guard let replacementString = replacementString else { return true } - // Fast path: skip the expensive isInsideCodeBlock scan for ordinary typing. + // Fast path: plain characters never trigger list/pair/arrow handling. if replacementString.count == 1, let ch = replacementString.first, ch != ">" && ch != "[" && ch != "(" && ch != "{" && @@ -109,9 +121,11 @@ struct MarkdownLists { return false } - let isInCodeBlock = textView.string.contains("`") - ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) - : false + let isInCodeBlock = isInsideCodeBlock ?? ( + textView.string.contains("`") + ? MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, in: textView.string) + : false + ) if replacementString == ">" && affectedCharRange.length == 0 && !isInCodeBlock { let insertionLocation = affectedCharRange.location @@ -204,12 +218,24 @@ struct MarkdownLists { // Horizontal rules render via the styler; source stays literal `---` so files round-trip. if currentLine.range(of: "^```\\w*$", options: .regularExpression) != nil { - let textBeforeLine = nsText.substring(to: currentLineRange.location) - let openingCount = textBeforeLine.components(separatedBy: "```").count - 1 + // Non-overlapping ``` count before the line (what + // components(separatedBy:).count-1 computed, without + // materializing an O(doc) substring array). + var openingCount = 0 + var searchLocation = 0 + while searchLocation < currentLineRange.location { + let found = nsText.range(of: "```", options: [], + range: NSRange(location: searchLocation, + length: currentLineRange.location - searchLocation)) + if found.location == NSNotFound { break } + openingCount += 1 + searchLocation = NSMaxRange(found) + } let afterLineStart = currentLineRange.location + currentLineRange.length let hasClosingAfter: Bool = { guard afterLineStart < nsText.length else { return false } - return nsText.substring(from: afterLineStart).contains("```") + let after = NSRange(location: afterLineStart, length: nsText.length - afterLineStart) + return nsText.range(of: "```", options: [], range: after).location != NSNotFound }() let lineEnd = currentLineRange.location + max(0, currentLineRange.length - 1) let cursorAtLineEnd = affectedCharRange.location >= lineEnd diff --git a/Sources/MarkdownEngine/Parser/BlockParser.swift b/Sources/MarkdownEngine/Parser/BlockParser.swift index a9e4b556..f779dbc4 100644 --- a/Sources/MarkdownEngine/Parser/BlockParser.swift +++ b/Sources/MarkdownEngine/Parser/BlockParser.swift @@ -41,6 +41,17 @@ struct Block: Equatable { let range: NSRange } +/// A resolved contiguous change between two buffer states, in UTF-16 units. +/// `changeStart ..< changeEndOld` in the old buffer was replaced by +/// `changeStart ..< changeEndNew` in the new one. The region may be wider +/// than the minimal diff — splice logic only requires containment. +struct BufferDiff { + let changeStart: Int + let changeEndOld: Int + let changeEndNew: Int + let delta: Int +} + enum BlockParser { private static let cacheLock = NSLock() @@ -48,27 +59,32 @@ enum BlockParser { private static var cachedBlocks: [Block]? /// Splits `text` into gap-free tiling blocks; memoizes the last parse so both per-keystroke callers share one line-scan. - static func parse(_ text: String) -> [Block] { + /// Pass `utf16Chars` when the caller already extracted the buffer (must match `text`). + static func parse(_ text: String, utf16Chars: [unichar]? = nil) -> [Block] { let textNS = text as NSString let newLen = textNS.length - var newChars = [unichar](repeating: 0, count: newLen) - if newLen > 0 { textNS.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) } + let newChars: [unichar] + if let utf16Chars, utf16Chars.count == newLen { + newChars = utf16Chars + } else { + var buffer = [unichar](repeating: 0, count: newLen) + if newLen > 0 { textNS.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } + newChars = buffer + } cacheLock.lock() let prevChars = cachedChars let prevBlocks = cachedBlocks cacheLock.unlock() - // Identical text → return cached (memcmp the buffer, not a slow bridged `String ==`). - if let prevChars, let prevBlocks, equalBuffers(prevChars, newChars) { - return prevBlocks - } - - // Incremental: reparse only the affected block window, else fall back to a full reparse. - if let prevChars, let prevBlocks, - let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS) { - cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cacheLock.unlock() - return incr + if let prevChars, let prevBlocks { + // Identical text → memcmp hit (the scan below would walk O(doc)). + if equalBuffers(prevChars, newChars) { return prevBlocks } + if let diff = scanDiff(old: prevChars, new: newChars), + let (incr, _) = incrementalParse(oldChars: prevChars, oldBlocks: prevBlocks, newChars: newChars, newNS: textNS, diff: diff) { + cacheLock.lock(); cachedChars = newChars; cachedBlocks = incr; cacheLock.unlock() + return incr + } } let blocks = computeBlocks(text) @@ -76,6 +92,14 @@ enum BlockParser { return blocks } + /// Adopt an externally computed parse (DocumentParseState publishes its + /// per-keystroke result) so static-path callers — the restyle's + /// DocumentAST.parse above all — take the memcmp hit instead of + /// re-splicing against a one-keystroke-stale cache. + static func seedCache(chars: [unichar], blocks: [Block]) { + cacheLock.lock(); cachedChars = chars; cachedBlocks = blocks; cacheLock.unlock() + } + private static func equalBuffers(_ a: [unichar], _ b: [unichar]) -> Bool { guard a.count == b.count else { return false } if a.isEmpty { return true } @@ -84,10 +108,42 @@ enum BlockParser { } } - /// Does `[lo, hi)` (± margin for an edit-boundary delimiter) contain a `$$` or ``` that can ripple? + /// Common prefix/suffix scan; nil when the buffers are identical. + static func scanDiff(old: [unichar], new: [unichar]) -> BufferDiff? { + let oldLen = old.count, newLen = new.count + var p = 0 + let maxPre = min(oldLen, newLen) + while p < maxPre, old[p] == new[p] { p += 1 } + if p == oldLen, oldLen == newLen { return nil } + var s = 0 + let maxSuf = maxPre - p + while s < maxSuf, old[oldLen - 1 - s] == new[newLen - 1 - s] { s += 1 } + return BufferDiff(changeStart: p, changeEndOld: oldLen - s, changeEndNew: newLen - s, delta: newLen - oldLen) + } + + /// Does any LINE touched by `[lo, hi)` contain a `$$` or ``` that can ripple? + /// Line-expanded, not just ±3 around the edit: block delimiters are + /// line-classified with a TRIMMED prefix (`isBlockLatexOpen`), so editing + /// the leading whitespace of an indented `$$` opener flips the pairing + /// from arbitrarily far away from the literal `$$`. The boundary walk is + /// capped; hitting the cap reports a delimiter (conservative full parse). static func hasBlockDelimiter(_ buf: [unichar], _ lo: Int, _ hi: Int) -> Bool { - var i = max(0, lo - 3) - let end = min(buf.count, hi + 3) + let cap = 4096 + var start = max(0, lo - 3) + var steps = 0 + while start > 0, buf[start - 1] != 0x0A, buf[start - 1] != 0x0D { + start -= 1 + steps += 1 + if steps > cap { return true } + } + var end = min(buf.count, hi + 3) + steps = 0 + while end < buf.count, buf[end] != 0x0A, buf[end] != 0x0D { + end += 1 + steps += 1 + if steps > cap { return true } + } + var i = start while i < end { if buf[i] == 0x24 { // $ if i + 1 < end, buf[i + 1] == 0x24 { return true } // $$ @@ -99,40 +155,50 @@ enum BlockParser { return false } - /// Diff old→new, reparse the affected window, splice between untouched prefix/suffix; nil to fall back to full. - private static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString) -> (blocks: [Block], window: Int)? { + /// Splice-parse against a precomputed change region (descriptor- or scan-derived): + /// reparse the affected block window, splice between untouched prefix/suffix; nil to fall back to full. + static func incrementalParse(oldChars o: [unichar], oldBlocks: [Block], newChars n: [unichar], newNS: NSString, diff: BufferDiff) -> (blocks: [Block], window: Int)? { guard !oldBlocks.isEmpty else { return nil } let oldLen = o.count, newLen = n.count guard oldLen > 0, newLen > 0 else { return nil } - // 1. Common prefix/suffix over the cached UTF-16 buffers (no re-extract). - var p = 0 - let maxPre = min(oldLen, newLen) - while p < maxPre, o[p] == n[p] { p += 1 } - var s = 0 - let maxSuf = maxPre - p - while s < maxSuf, o[oldLen - 1 - s] == n[newLen - 1 - s] { s += 1 } - let delta = newLen - oldLen - let changeStart = p - let changeEnd = oldLen - s // [changeStart, changeEnd) in old + let delta = diff.delta + let changeStart = diff.changeStart + let changeEnd = diff.changeEndOld // [changeStart, changeEnd) in old + guard changeStart >= 0, changeEnd <= oldLen, diff.changeEndNew <= newLen, + changeStart <= changeEnd, changeStart <= diff.changeEndNew else { return nil } // A fence/block-LaTeX delimiter in the edit can pair with a distant partner → full reparse. - if hasBlockDelimiter(o, changeStart, changeEnd) || hasBlockDelimiter(n, changeStart, newLen - s) { + if hasBlockDelimiter(o, changeStart, changeEnd) || hasBlockDelimiter(n, changeStart, diff.changeEndNew) { return nil } // 2. Affected old-block window (±1 block margin for merges/splits). - var firstIdx = 0 - while firstIdx + 1 < oldBlocks.count, oldBlocks[firstIdx + 1].range.location <= changeStart { firstIdx += 1 } - var lastIdx = oldBlocks.count - 1 - while lastIdx > 0, NSMaxRange(oldBlocks[lastIdx - 1].range) >= changeEnd { lastIdx -= 1 } + // Blocks tile the document in order — binary search instead of the + // linear walks that cost O(#blocks) per keystroke in large documents. + var lo = 0, hi = oldBlocks.count - 1 + while lo < hi { // last block starting <= changeStart + let m = (lo + hi + 1) / 2 + if oldBlocks[m].range.location <= changeStart { lo = m } else { hi = m - 1 } + } + let firstIdx = lo + lo = 0; hi = oldBlocks.count - 1 + while lo < hi { // first block ending >= changeEnd + let m = (lo + hi) / 2 + if NSMaxRange(oldBlocks[m].range) >= changeEnd { hi = m } else { lo = m + 1 } + } + let lastIdx = lo let winFirst = max(0, min(firstIdx, lastIdx) - 1) let winLast = min(oldBlocks.count - 1, max(firstIdx, lastIdx) + 1) - // 3. Bail on opaque multi-line blocks — fences / block LaTeX can ripple. - for b in oldBlocks[winFirst...winLast] where b.kind == .fencedCode || b.kind == .blockLatex { - return nil - } + // 3. Opaque multi-line blocks (fences / block LaTeX) in the window are + // fine for INTERIOR edits: the window contains each block wholly, the + // ±3 delimiter guard above already bailed on any edit that creates, + // destroys, or touches a ``` / $$ pairing, and an edit that UN-closes + // a block (trailing chars on its closer line) makes the reparsed block + // reach the window end — caught by the trailing guard below. Typing + // inside a code block used to fall back to a full O(doc) reparse on + // every keystroke because of an unconditional bail here. // 4. Window → new-text range (window start is before the edit → unchanged). let winStart = oldBlocks[winFirst].range.location @@ -166,7 +232,7 @@ enum BlockParser { return (result, reparsed.count) } - private static func computeBlocks(_ text: String) -> [Block] { + static func computeBlocks(_ text: String) -> [Block] { let nsText = text as NSString let length = nsText.length guard length > 0 else { return [] } diff --git a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift index 3bb7aa8c..4ed5b14d 100644 --- a/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift +++ b/Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift @@ -27,12 +27,16 @@ extension MarkdownTokenizer { /// The live tokenizer: block-level tokens + inline AST tokens; fenced code emits only its code-block token. static func parseTokensViaAST(in text: String) -> [MarkdownToken] { + let t0 = DispatchTime.now().uptimeNanoseconds + defer { + PerfTrace.note { "🗜️ tokenizer.static \(String(format: "%.2f", Double(DispatchTime.now().uptimeNanoseconds - t0) / 1_000_000))ms" } + } let ns = text as NSString let newLen = ns.length var newChars = [unichar](repeating: 0, count: newLen) if newLen > 0 { ns.getCharacters(&newChars, range: NSRange(location: 0, length: newLen)) } - let blocks = BlockParser.parse(text) + let blocks = BlockParser.parse(text, utf16Chars: newChars) tokensLock.lock() let prevChars = cachedTokenChars @@ -40,9 +44,13 @@ extension MarkdownTokenizer { tokensLock.unlock() let result: [MarkdownToken] - if let prevChars, let prevTokens, - let (incr, _) = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns) { - result = incr + if let prevChars, let prevTokens { + if let diff = BlockParser.scanDiff(old: prevChars, new: newChars) { + result = incrementalTokens(oldChars: prevChars, prevTokens: prevTokens, newChars: newChars, blocks: blocks, ns: ns, diff: diff)?.tokens + ?? fullTokens(blocks: blocks, ns: ns) + } else { + result = prevTokens // identical text + } } else { result = fullTokens(blocks: blocks, ns: ns) } @@ -51,7 +59,14 @@ extension MarkdownTokenizer { return result } - private static func fullTokens(blocks: [Block], ns: NSString) -> [MarkdownToken] { + /// Adopt an externally computed parse (DocumentParseState publishes its + /// per-keystroke result) so static-path callers hit instead of re-splicing + /// against a one-keystroke-stale cache. + static func seedCache(chars: [unichar], tokens: [MarkdownToken]) { + tokensLock.lock(); cachedTokenChars = chars; cachedTokens = tokens; tokensLock.unlock() + } + + static func fullTokens(blocks: [Block], ns: NSString) -> [MarkdownToken] { var result: [MarkdownToken] = [] for block in blocks { let delta = block.range.location @@ -61,31 +76,42 @@ extension MarkdownTokenizer { return result } - /// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks; nil to fall back to full. - private static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString) -> (tokens: [MarkdownToken], retok: Int)? { + /// Reuse prefix/suffix tokens (suffix shifted) and re-tokenize only touched blocks, + /// against a precomputed change region; nil to fall back to full. + static func incrementalTokens(oldChars o: [unichar], prevTokens: [MarkdownToken], newChars n: [unichar], blocks: [Block], ns: NSString, diff: BufferDiff) -> (tokens: [MarkdownToken], retok: Int)? { let oldLen = o.count, newLen = n.count guard oldLen > 0, newLen > 0, !blocks.isEmpty else { return nil } - var p = 0 - let maxPre = min(oldLen, newLen) - while p < maxPre, o[p] == n[p] { p += 1 } - var s = 0 - let maxSuf = maxPre - p - while s < maxSuf, o[oldLen - 1 - s] == n[newLen - 1 - s] { s += 1 } - let delta = newLen - oldLen - let changeStart = p, changeEndNew = newLen - s + let delta = diff.delta + let changeStart = diff.changeStart, changeEndNew = diff.changeEndNew + guard changeStart >= 0, diff.changeEndOld <= oldLen, changeEndNew <= newLen, + changeStart <= diff.changeEndOld, changeStart <= changeEndNew else { return nil } // A fence/block-LaTeX delimiter can pair with a distant partner and ripple far → full tokenization. - if BlockParser.hasBlockDelimiter(o, changeStart, oldLen - s) + if BlockParser.hasBlockDelimiter(o, changeStart, diff.changeEndOld) || BlockParser.hasBlockDelimiter(n, changeStart, changeEndNew) { return nil } // New blocks touching the changed char range [changeStart, changeEndNew]. - var lo = blocks.count, hi = -1 - for (i, b) in blocks.enumerated() - where b.range.location <= changeEndNew && NSMaxRange(b.range) >= changeStart { - lo = min(lo, i); hi = max(hi, i) + // Blocks tile in order → the touching set is one contiguous run; + // binary search replaces the O(#blocks) full scan per keystroke. + var lo = 0, hi = blocks.count - 1 + while lo < hi { // first block ending >= changeStart + let m = (lo + hi) / 2 + if NSMaxRange(blocks[m].range) >= changeStart { hi = m } else { lo = m + 1 } + } + var first = lo + lo = 0; hi = blocks.count - 1 + while lo < hi { // last block starting <= changeEndNew + let m = (lo + hi + 1) / 2 + if blocks[m].range.location <= changeEndNew { lo = m } else { hi = m - 1 } + } + let last = lo + // Validate the run actually touches (mirrors the old filter exactly). + if first > last || blocks[first].range.location > changeEndNew || NSMaxRange(blocks[last].range) < changeStart { + return delta == 0 ? (prevTokens, 0) : nil } - if hi < 0 { return delta == 0 ? (prevTokens, 0) : nil } + lo = first + hi = last // Widen the window until no previous token straddles either cut (a block's extent can change in place). var expanded = true @@ -118,7 +144,7 @@ extension MarkdownTokenizer { } /// Cached block-relative tokens for `sub` (computed on miss); a pure memo over the token logic. - private static func cachedBlockTokens(kind: BlockKind, sub: String) -> [MarkdownToken] { + static func cachedBlockTokens(kind: BlockKind, sub: String) -> [MarkdownToken] { blockTokenLock.lock() if let cached = blockTokenCache[sub] { blockTokenLock.unlock() diff --git a/Sources/MarkdownEngine/Parser/DocumentParseState.swift b/Sources/MarkdownEngine/Parser/DocumentParseState.swift new file mode 100644 index 00000000..18e74af7 --- /dev/null +++ b/Sources/MarkdownEngine/Parser/DocumentParseState.swift @@ -0,0 +1,152 @@ +// +// DocumentParseState.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Per-editor incremental parse state: one UTF-16 buffer, its block list, and +// its token list evolve together under a single edit descriptor. A keystroke +// then pays one O(edit) buffer splice and a block-window re-tokenize instead +// of a full-document re-extraction plus two independent O(doc) prefix/suffix +// diff scans (BlockParser and the tokenizer each ran their own). +// + +import Foundation + +/// A contiguous edit in NEW-text coordinates plus the length delta, as +/// delivered by shouldChangeTextIn/textDidChange. The described region may be +/// wider than the minimal diff — splice logic only requires containment. +struct ParseEditDescriptor { + let editedRange: NSRange // post-edit coords: location + replacement length + let delta: Int +} + +final class DocumentParseState { + private let lock = NSLock() + private var chars: [unichar] = [] + private var blocks: [Block] = [] + private var tokens: [MarkdownToken] = [] + private var valid = false +#if DEBUG + private var verifyCounter: UInt = 0 +#endif + + /// The block list matching the most recent `tokens(for:edit:)` call — + /// handed to the restyle so DocumentAST.parse skips the block parser. + var currentBlocks: [Block] { + lock.lock(); defer { lock.unlock() } + return blocks + } + + /// Drop all state (document switch / full rebuild) — the next parse + /// re-extracts and re-parses from scratch. + func invalidate() { + lock.lock() + valid = false + chars = []; blocks = []; tokens = [] + lock.unlock() + } + + /// Tokens for `text`. With a trustworthy `edit` the update is + /// O(edit + touched blocks + suffix shift); without one, a single shared + /// O(doc) diff scan replaces the two independent scans of the static path. + func tokens(for text: String, edit: ParseEditDescriptor?) -> [MarkdownToken] { + let ns = text as NSString + let newLen = ns.length + let tStart = DispatchTime.now().uptimeNanoseconds + + lock.lock() + let prevChars = chars + let prevBlocks = blocks + let prevTokens = tokens + let wasValid = valid + lock.unlock() + + // 1. New buffer + change region — spliced O(edit) when the descriptor + // passes every sanity check, extracted O(doc) otherwise. + var newChars: [unichar] + var diff: BufferDiff? + if wasValid, let edit, + edit.delta != Int.min, + edit.editedRange.location != NSNotFound, + edit.editedRange.location >= 0, edit.editedRange.length >= 0, + NSMaxRange(edit.editedRange) <= newLen, + edit.editedRange.length - edit.delta >= 0, + prevChars.count == newLen - edit.delta { + let changeStart = edit.editedRange.location + let changeEndNew = NSMaxRange(edit.editedRange) + let changeEndOld = changeEndNew - edit.delta + var replacement = [unichar](repeating: 0, count: edit.editedRange.length) + if edit.editedRange.length > 0 { ns.getCharacters(&replacement, range: edit.editedRange) } + newChars = prevChars + newChars.replaceSubrange(changeStart.. 0 { ns.getCharacters(&fresh, range: NSRange(location: 0, length: newLen)) } + assert(fresh == newChars, "spliced parse buffer diverged from the text storage") + } +#endif + } else { + var buffer = [unichar](repeating: 0, count: newLen) + if newLen > 0 { ns.getCharacters(&buffer, range: NSRange(location: 0, length: newLen)) } + newChars = buffer + if wasValid { + diff = BlockParser.scanDiff(old: prevChars, new: newChars) + if diff == nil, prevChars.count == newLen { + return prevTokens // identical text + } + } + } + + let tBuffer = DispatchTime.now().uptimeNanoseconds + + // 2. Blocks: window splice on the shared diff, full reparse fallback. + var newBlocks: [Block]? + if wasValid, let diff { + newBlocks = BlockParser.incrementalParse( + oldChars: prevChars, oldBlocks: prevBlocks, + newChars: newChars, newNS: ns, diff: diff + )?.blocks + } + let resolvedBlocks = newBlocks ?? BlockParser.computeBlocks(text) + let tBlocks = DispatchTime.now().uptimeNanoseconds + + // 3. Tokens: prefix/suffix reuse on the same diff, full fallback. + var newTokens: [MarkdownToken]? + if wasValid, let diff, newBlocks != nil { + newTokens = MarkdownTokenizer.incrementalTokens( + oldChars: prevChars, prevTokens: prevTokens, + newChars: newChars, blocks: resolvedBlocks, ns: ns, diff: diff + )?.tokens + } + let resolvedTokens = newTokens ?? MarkdownTokenizer.fullTokens(blocks: resolvedBlocks, ns: ns) + let tTokens = DispatchTime.now().uptimeNanoseconds + PerfTrace.note { + let ms = { (a: UInt64, b: UInt64) in String(format: "%.2f", Double(b - a) / 1_000_000) } + let blockMode = newBlocks != nil ? "splice" : "FULL" + let tokenMode = newTokens != nil ? "incremental" : "FULL" + return "parseState split: buffer=\(ms(tStart, tBuffer))ms blocks(\(blockMode))=\(ms(tBuffer, tBlocks))ms tokens(\(tokenMode))=\(ms(tBlocks, tTokens))ms #blocks=\(resolvedBlocks.count) #tokens=\(resolvedTokens.count)" + } + + lock.lock() + chars = newChars + blocks = resolvedBlocks + tokens = resolvedTokens + valid = true + lock.unlock() + + // Publish to the static memos so their callers (restyle's + // DocumentAST.parse, smart-input helpers) take the memcmp hit instead + // of splicing against a one-keystroke-stale cache every time. + BlockParser.seedCache(chars: newChars, blocks: resolvedBlocks) + MarkdownTokenizer.seedCache(chars: newChars, tokens: resolvedTokens) + return resolvedTokens + } +} diff --git a/Sources/MarkdownEngine/Parser/MarkdownAST.swift b/Sources/MarkdownEngine/Parser/MarkdownAST.swift index 0b777d8b..e32aa93d 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownAST.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownAST.swift @@ -53,11 +53,32 @@ enum DocumentAST { private static let tab: unichar = 0x09 /// Build the document AST; `scopedRanges` parses inlines only for intersecting blocks. - static func parse(_ text: String, scopedRanges: [NSRange]? = nil) -> [BlockNode] { + /// `precomputedBlocks` (the keystroke's own parse state, handed down by the + /// restyle) skips BlockParser.parse — whose cache "hit" still re-extracts + /// and memcmps the full document buffer — entirely. + static func parse(_ text: String, scopedRanges: [NSRange]? = nil, precomputedBlocks: [Block]? = nil) -> [BlockNode] { let ns = text as NSString - let blocks = BlockParser.parse(text) + let blocks = precomputedBlocks ?? BlockParser.parse(text) // Scoped mode: skip building BlockNodes for blocks outside the edit. - let relevant = scopedRanges == nil ? blocks : blocks.filter { inScope($0.range, scopedRanges) } + // Blocks tile the document in order, so one sweep over sorted candidate + // ranges replaces scanning every candidate per block (which went + // quadratic in formula-rich documents with dozens of candidates). + let relevant: [Block] + if let scopedRanges { + let sorted = scopedRanges + .filter { $0.location != NSNotFound && $0.length > 0 } + .sorted { $0.location < $1.location } + var out: [Block] = [] + var ci = 0 + for block in blocks { + while ci < sorted.count, NSMaxRange(sorted[ci]) <= block.range.location { ci += 1 } + guard ci < sorted.count else { break } + if sorted[ci].location < NSMaxRange(block.range) { out.append(block) } + } + relevant = out + } else { + relevant = blocks + } return relevant.map { node(for: $0, ns: ns, scopedRanges: scopedRanges) } } diff --git a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift index e6ff9666..afe72d78 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift @@ -95,6 +95,54 @@ enum MarkdownDetection { isInsideCodeBlock(range: NSRange(location: location, length: 0), codeTokens: codeTokens) } + /// Count of non-overlapping ``` occurrences, scanning left to right — + /// exactly `components(separatedBy: "```").count - 1`, but as one UTF-16 + /// pass with no substring-array allocation. + static func tripleBacktickCount(in text: NSString) -> Int { + let length = text.length + guard length >= 3 else { return 0 } + var buffer = [unichar](repeating: 0, count: length) + text.getCharacters(&buffer, range: NSRange(location: 0, length: length)) + var count = 0 + var i = 0 + while i + 2 < length { // i can reach length - 3 + if buffer[i] == 0x60, buffer[i + 1] == 0x60, buffer[i + 2] == 0x60 { + count += 1 + i += 3 + } else { + i += 1 + } + } + return count + } + + /// The ``` count contributed by the backtick runs that intersect `range`. + /// The window expands through adjacent backticks on both sides, so every + /// run inside it is a MAXIMAL run of the whole text — and the greedy global + /// count is exactly Σ floor(runLen/3) over maximal runs, which makes these + /// window counts composable: full = fullBefore − windowBefore + windowAfter. + static func backtickWindowCount(in text: NSString, around range: NSRange) -> Int { + let length = text.length + guard range.location >= 0, NSMaxRange(range) <= length else { return 0 } + var lo = range.location + while lo > 0, text.character(at: lo - 1) == 0x60 { lo -= 1 } + var hi = NSMaxRange(range) + while hi < length, text.character(at: hi) == 0x60 { hi += 1 } + var count = 0 + var run = 0 + var i = lo + while i < hi { + if text.character(at: i) == 0x60 { + run += 1 + } else { + count += run / 3 + run = 0 + } + i += 1 + } + return count + run / 3 + } + // MARK: - LaTeX Detection static func isInsideLatex(location: Int, in text: String) -> Bool { diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index cdffd74b..3184dcd2 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -614,6 +614,15 @@ final class MarkdownLayoutManagerDelegate: NSObject, NSTextLayoutManagerDelegate _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: any NSTextLocation, in textElement: NSTextElement + ) -> NSTextLayoutFragment { + PerfTrace.accumulate("fragProv") { + makeFragment(textLayoutManager: textLayoutManager, textElement: textElement) + } + } + + private func makeFragment( + textLayoutManager: NSTextLayoutManager, + textElement: NSTextElement ) -> NSTextLayoutFragment { let fragment = MarkdownTextLayoutFragment(textElement: textElement, range: textElement.elementRange) // Seed body font + paragraphStyle so the trailing fragment doesn't inherit heading metrics (FB15131180). diff --git a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift index d6b18d23..fabd480c 100644 --- a/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift +++ b/Sources/MarkdownEngine/Renderer/WideTableOverlay.swift @@ -233,18 +233,29 @@ extension NativeTextView { let fullRange = NSRange(location: 0, length: storage.length) // Cheap presence-check first: skip the full-document layout pass when - // the doc has no wide tables. enumerateAttribute stops on first hit. + // the doc has no wide tables. enumerateAttribute stops on first hit — + // but a MISS walks every attribute run in the document (scheduled + // after each restyle), so stamp it when it gets slow. + let presenceT0 = DispatchTime.now().uptimeNanoseconds var hasAnyWideTable = false storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, _, stop in if value is Int { hasAnyWideTable = true; stop.pointee = true } } + let presenceMs = Double(DispatchTime.now().uptimeNanoseconds - presenceT0) / 1_000_000 + if presenceMs > 0.3 { + PerfTrace.stamp("wideTableOverlay.presenceScan", presenceMs, "wide=\(hasAnyWideTable ? 1 : 0) docLen=\(storage.length)") + } guard hasAnyWideTable else { removeAllWideTableOverlays() return } // Settle layout before measuring — stale fragments would yield wrong anchor Ys. + let overlayT0 = DispatchTime.now().uptimeNanoseconds tlm.ensureLayout(for: tlm.documentRange) + PerfTrace.stamp("wideTableOverlay.ensureLayout(fullDoc)", + Double(DispatchTime.now().uptimeNanoseconds - overlayT0) / 1_000_000, + "docLen=\(storage.length)") storage.enumerateAttribute(.scrollableBlockSourceID, in: fullRange, options: []) { value, attrRange, _ in guard let sourceID = value as? Int, diff --git a/Sources/MarkdownEngine/Services/WikiLinkService.swift b/Sources/MarkdownEngine/Services/WikiLinkService.swift index 6a265128..47db0d23 100644 --- a/Sources/MarkdownEngine/Services/WikiLinkService.swift +++ b/Sources/MarkdownEngine/Services/WikiLinkService.swift @@ -189,6 +189,84 @@ public enum WikiLinkService { return (storage, metadata) } + /// Incremental counterpart to `makeStorageState`: splice a single contiguous + /// edit into the previous storage form in O(edit + #links). + /// + /// Outside link syntax, display and storage text are identical, so an edit + /// that provably cannot create, destroy, or touch a link maps 1:1 into the + /// storage string; links after the edit just shift by the length delta. + /// Returns nil whenever that proof fails — callers fall back to the full + /// `makeStorageState` rebuild. + public static func updatedStorageState( + displayText: String, + editedRange: NSRange, + changeInLength delta: Int, + previousStorage: String, + previousMetadata: [RangeKey: LinkMetadata] + ) -> (storage: String, metadata: [RangeKey: LinkMetadata])? { + let nsDisplay = displayText as NSString + let nsPrevStorage = previousStorage as NSString + + // Only contiguous, small, well-formed edits take the fast path. + guard delta != Int.min, + editedRange.location != NSNotFound, + editedRange.length >= 0, editedRange.length <= 4096, + NSMaxRange(editedRange) <= nsDisplay.length, + editedRange.length - delta >= 0 else { return nil } + + let oldEditLength = editedRange.length - delta + let oldEditRange = NSRange(location: editedRange.location, length: oldEditLength) + + // The edit must not create or complete link syntax: no [[ or ]] near it + // in the NEW text (±3 covers a bracket typed against an existing one)… + let probeStart = max(0, editedRange.location - 3) + let probeEnd = min(nsDisplay.length, NSMaxRange(editedRange) + 3) + let probe = nsDisplay.substring(with: NSRange(location: probeStart, length: probeEnd - probeStart)) + if probe.contains("[[") || probe.contains("]]") { return nil } + + // …and no existing link may overlap the edit (old display coordinates; + // ±3 also rejects edits adjacent to a link's markers). + let guardRange = NSRange(location: max(0, oldEditRange.location - 3), length: oldEditLength + 6) + for key in previousMetadata.keys { + if NSIntersectionRange(NSRange(location: key.location, length: key.length), guardRange).length > 0 { + return nil + } + } + + // Map the display edit offset into storage coordinates: every link + // before the edit is longer in storage by (storage length − display length). + var storageOffsetDelta = 0 + for (key, meta) in previousMetadata where key.location < editedRange.location { + storageOffsetDelta += meta.storageRange.length - key.length + } + let storageEditStart = editedRange.location + storageOffsetDelta + guard storageEditStart >= 0, + storageEditStart + oldEditLength <= nsPrevStorage.length else { return nil } + + // Splice — outside links the replaced/inserted characters are identical + // in both forms. + let replacement = nsDisplay.substring(with: editedRange) + let storage = nsPrevStorage.replacingCharacters( + in: NSRange(location: storageEditStart, length: oldEditLength), + with: replacement + ) + + // Shift every link after the edit by the delta; links before it are untouched. + var metadata: [RangeKey: LinkMetadata] = [:] + metadata.reserveCapacity(previousMetadata.count) + for (key, meta) in previousMetadata { + if key.location >= NSMaxRange(oldEditRange) { + metadata[RangeKey(NSRange(location: key.location + delta, length: key.length))] = + LinkMetadata(id: meta.id, + storageRange: NSRange(location: meta.storageRange.location + delta, + length: meta.storageRange.length)) + } else { + metadata[key] = meta + } + } + return (storage, metadata) + } + /// Hand scan for display-form wiki links `(? [(range: NSRange, isImage: Bool)] { let len = s.length diff --git a/Sources/MarkdownEngine/Styling/HeadingHelpers.swift b/Sources/MarkdownEngine/Styling/HeadingHelpers.swift index 86234f59..49bb7218 100644 --- a/Sources/MarkdownEngine/Styling/HeadingHelpers.swift +++ b/Sources/MarkdownEngine/Styling/HeadingHelpers.swift @@ -11,13 +11,15 @@ import AppKit enum HeadingHelpers { /// Use heading context to scale LaTeX font size consistently with surrounding text. + /// `headings` is the document's heading tokens, built once per styling pass — + /// scanning all tokens per LaTeX token here was O(#latex × #tokens). static func latexFontSize( for token: MarkdownToken, - tokens: [MarkdownToken], + headings: [MarkdownToken], baseFont: NSFont, configuration: HeadingStyle = .default ) -> CGFloat { - if let headingToken = tokens.first(where: { $0.kind == .heading && NSLocationInRange(token.contentRange.location, $0.contentRange) }) { + if let headingToken = headings.first(where: { NSLocationInRange(token.contentRange.location, $0.contentRange) }) { let level = headingToken.markerRanges.first?.length ?? 1 return baseFont.pointSize * configuration.fontMultiplier(for: level) } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 51a53942..6c9e91fa 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -29,6 +29,7 @@ enum MarkdownASTStyler { caretLocation: Int = -1, wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, scopedRanges: [NSRange]? = nil, + precomputedBlocks: [Block]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { let baseFont = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) @@ -64,7 +65,7 @@ enum MarkdownASTStyler { wikiLinkID: wikiLinkIDProvider, scopedRanges: scopedRanges ) - let blocks = DocumentAST.parse(text, scopedRanges: scopedRanges) + let blocks = DocumentAST.parse(text, scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks) var attrs: [StyledRange] = [] for block in blocks where ctx.inScope(block.range) { styleBlock(block, font: baseFont, ctx: ctx, into: &attrs) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift index 08e74764..dad93d1d 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Images.swift @@ -20,7 +20,7 @@ extension MarkdownStyler { /// at which point we fall back to dimming the markdown source). static func styleImageLinks(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageLink { + for (idx, token) in ctx.scoped(ctx.imageLinkIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } // The URL lives between markerRanges[2] ('(') and markerRanges[3] (')'). @@ -114,7 +114,7 @@ extension MarkdownStyler { static func styleImageEmbeds(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - for (idx, token) in ctx.tokens.enumerated() where token.kind == .imageEmbed { + for (idx, token) in ctx.scoped(ctx.imageEmbedIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift index 4c89d22d..90789fa7 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift @@ -16,8 +16,7 @@ extension MarkdownStyler { static func styleBlockLatex(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] - let blockLatexTokens = ctx.tokens.enumerated().filter { $0.element.kind == .blockLatex } - for (idx, token) in blockLatexTokens { + for (idx, token) in ctx.scoped(ctx.blockLatexIndexed) { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } let isActive = ctx.activeTokenIndices.contains(idx) let rawLatexContent = ctx.nsText.substring(with: token.contentRange) @@ -27,7 +26,7 @@ extension MarkdownStyler { guard token.standaloneParagraphRange(in: ctx.nsText) != nil else { continue } - let latexFontSize = HeadingHelpers.latexFontSize(for: token, tokens: ctx.tokens, baseFont: ctx.baseFont) + let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: [], baseFont: ctx.baseFont) // block $$ is never inside a heading if isActive { appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme) @@ -67,10 +66,16 @@ extension MarkdownStyler { // draws that tiny inline image on the collapsed 1pt source line under // the table — visible as a stray dot. Skip inline LaTeX inside a // table; the table image already covers it. - let tableRanges = ctx.tokens.filter { $0.kind == .table }.map(\.range) + let scopedLatex = ctx.scoped(ctx.inlineLatexIndexed) + guard !scopedLatex.isEmpty else { return attrs } + // Containers that ENCLOSE an in-scope formula must overlap the scope, so + // scope-slicing these is exact; built once, not per formula. + let tableRanges = ctx.scoped(ctx.tableIndexed).map { $0.token.range } // Quote lines mute their text via foregroundColor, which the LaTeX *image* ignores — render it in mutedText instead so it matches the grey. - let blockquoteRanges = ctx.tokens.filter { $0.kind == .blockquote }.map(\.range) - for (idx, token) in ctx.tokens.enumerated() where token.kind == .inlineLatex { + let blockquoteRanges = MarkdownStyler.StylingContext.indexed(ctx.tokens, .blockquote).map { $0.token.range } + // Built once, not re-scanned per formula (latexFontSize was O(#latex × #tokens)). + let headings = ctx.scoped(MarkdownStyler.StylingContext.indexed(ctx.tokens, .heading)).map { $0.token } + for (idx, token) in scopedLatex { if MarkdownDetection.isInsideCodeBlock(range: token.range, codeTokens: ctx.codeTokens) { continue } if tableRanges.contains(where: { tableRange in token.range.location >= tableRange.location @@ -81,7 +86,7 @@ extension MarkdownStyler { let isActive = ctx.activeTokenIndices.contains(idx) let latexContent = ctx.nsText.substring(with: token.contentRange) - let latexFontSize = HeadingHelpers.latexFontSize(for: token, tokens: ctx.tokens, baseFont: ctx.baseFont) + let latexFontSize = HeadingHelpers.latexFontSize(for: token, headings: headings, baseFont: ctx.baseFont) if isActive { for markerRange in token.markerRanges { diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 8fb543d0..80da7593 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -25,21 +25,184 @@ extension MarkdownStyler { let rows: [[String]] } + /// Rendered-table image cache. A table's pixels depend only on its source, + /// font, colors, and appearance — so identical keys can reuse the NSImage + /// instead of re-rendering every inactive table on every keystroke. + private static let tableImageCache: NSCache = { + let cache = NSCache() + // Must exceed a document's unique-table count or a full restyle (load / + // theme / font change) re-renders every table (same thrash class as the + // metadata cap). NSCache still auto-evicts under memory pressure. + cache.countLimit = 2048 + return cache + }() + + /// Pixel-level fingerprint of a theme color: its sRGB components resolved + /// under `appearance`. NSColor descriptions are not sound identities — + /// named dynamic colors describe by name only (two providers collide), + /// unnamed ones by per-instance UUID (never hit) — so key on what actually + /// reaches the bitmap. + private static func colorKey(_ color: NSColor, under appearance: NSAppearance) -> String { + var srgb: NSColor? + appearance.performAsCurrentDrawingAppearance { + srgb = color.usingColorSpace(.sRGB) + } + guard let c = srgb else { return "\(color)" } + return String(format: "%.4f,%.4f,%.4f,%.4f", + c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent) + } + + /// Resolving six colors per table per keystroke is measurable (10 tables × + /// 6 appearance-scoped resolutions). The resolved prefix depends only on + /// the color INSTANCES + appearance + font, so memoize it by identity — + /// theme copies keep the same NSColor references across keystrokes. + private static let themeKeyLock = NSLock() + private static var themeKeyCache: [String: String] = [:] + + private static func themeKeyPrefix(ctx: StylingContext, appearance: NSAppearance) -> String { + let theme = ctx.configuration.theme + let identity = "\(ctx.baseFont.fontName)|\(ctx.baseFont.pointSize)|\(appearance.name.rawValue)|" + + "\(ObjectIdentifier(theme.bodyText))|\(ObjectIdentifier(theme.mutedText))|" + + "\(ObjectIdentifier(theme.highlightColor))|\(ObjectIdentifier(ctx.codeBackgroundColor))|" + + "\(ObjectIdentifier(theme.latexLightModeText))|\(ObjectIdentifier(theme.latexDarkModeText))|" + + "\(ObjectIdentifier(type(of: ctx.services.latex)))" + + themeKeyLock.lock() + if let cached = themeKeyCache[identity] { + themeKeyLock.unlock() + return cached + } + themeKeyLock.unlock() + + // Every input renderTable reads must be in the key: fonts, all theme + // colors it draws with, and the latex renderer (by type — a NoOp and a + // real renderer must not share entries). + let prefix = [ + ctx.baseFont.fontName, + "\(ctx.baseFont.pointSize)", + appearance.name.rawValue, + colorKey(theme.bodyText, under: appearance), + colorKey(theme.mutedText, under: appearance), + colorKey(theme.highlightColor, under: appearance), + colorKey(ctx.codeBackgroundColor, under: appearance), + colorKey(theme.latexLightModeText, under: appearance), + colorKey(theme.latexDarkModeText, under: appearance), + "\(ObjectIdentifier(type(of: ctx.services.latex)))", + ].joined(separator: "|") + + themeKeyLock.lock() + if themeKeyCache.count > 32 { themeKeyCache.removeAll() } + themeKeyCache[identity] = prefix + themeKeyLock.unlock() + return prefix + } + + /// Parse + content-hash for a table source, memoized: both are pure in + /// the source text but were recomputed for every table on every keystroke + /// (the non-render share of styleTables). FIFO-capped like the block token + /// memo — the cap MUST exceed a document's table count, else cyclic access + /// over the full table set is Bélády-pessimal under FIFO (~100% miss) and + /// every table re-parses+re-hashes every keystroke. + private static let tableMetaCap = 8192 + private static let tableMetaLock = NSLock() + private static var tableMetaCache: [String: (parsed: ParsedTable?, hash: Int)] = [:] + private static var tableMetaOrder: [String] = [] + + static func tableMeta(for source: String) -> (parsed: ParsedTable?, hash: Int) { + tableMetaLock.lock() + if let cached = tableMetaCache[source] { + tableMetaLock.unlock() + return cached + } + tableMetaLock.unlock() + + let computed = (parseTableSource(source), stableTableContentHash(for: source)) + + tableMetaLock.lock() + if tableMetaCache[source] == nil { + tableMetaCache[source] = computed + tableMetaOrder.append(source) + if tableMetaOrder.count > tableMetaCap { + tableMetaCache[tableMetaOrder.removeFirst()] = nil + } + } + tableMetaLock.unlock() + return computed + } + + /// Returns the rendered image for `source`, from cache when possible. + /// `rendered` is true only when a fresh render actually happened. + /// `availableWidth` caps the table's width (cells wrap onto extra lines); + /// it is part of the cache key because the layout depends on it. + static func tableImage( + for source: String, + parsed: ParsedTable, + ctx: StylingContext, + appearance: NSAppearance, + availableWidth: CGFloat + ) -> (image: NSImage, rendered: Bool) { + let widthKey = Int(availableWidth.rounded()) + let key = (themeKeyPrefix(ctx: ctx, appearance: appearance) + "|w\(widthKey)|" + source) as NSString + if let cached = tableImageCache.object(forKey: key) { + return (cached, false) + } + let image = renderTable( + parsed, + baseFont: ctx.baseFont, + theme: ctx.configuration.theme, + codeBackgroundColor: ctx.codeBackgroundColor, + latex: ctx.services.latex, + appearance: appearance, + availableWidth: availableWidth + ) + tableImageCache.setObject(image, forKey: key) + return (image, true) + } + static func styleTables(_ ctx: StylingContext) -> [StyledRange] { var attrs: [StyledRange] = [] // Per-content occurrence counter so identical tables get distinct sourceIDs. var occurrenceByContentHash: [Int: Int] = [:] - for (idx, token) in ctx.tokens.enumerated() where token.kind == .table { + var tableCount = 0 + var renderedCount = 0 + let tablesT0 = DispatchTime.now().uptimeNanoseconds + // Iterate the pre-classified table array (not all document tokens). + // The occurrence counter exists for stable duplicate-table sourceIDs, + // and a sourceID is only CONSUMED by a table that renders this pass + // (inactive + in scope). Equal content implies equal source length, + // so only tables sharing a length with a rendering table can affect + // its occurrence index — every other inactive table skips the + // substring + parse/hash AND the .spellingState write (applied + // UNCLIPPED, it used to touch every table in the document on every + // keystroke). Typing prose renders no table → all tables skip. + let tableIndexed = ctx.tableIndexed + var neededLengths: Set = [] + for (idx, token) in tableIndexed + where !ctx.activeTokenIndices.contains(idx) && !ctx.outsideScope(token.range) { + neededLengths.insert(token.range.length) + } + var skippedCount = 0 + var metaNanos: UInt64 = 0 + for (idx, token) in tableIndexed { + tableCount += 1 + if !ctx.activeTokenIndices.contains(idx), + !neededLengths.contains(token.range.length) { + skippedCount += 1 + continue + } // Tokenizer already drops tables overlapping fenced code, so no re-check here. attrs.append((token.range, [.spellingState: 0])) + let metaT0 = DispatchTime.now().uptimeNanoseconds let source = ctx.nsText.substring(with: token.range) - guard let parsed = parseTableSource(source) else { continue } + let meta = tableMeta(for: source) + metaNanos &+= DispatchTime.now().uptimeNanoseconds - metaT0 + guard let parsed = meta.parsed else { continue } - // Advance occurrence index even for active tables so inactive duplicates stay stable. - let contentHash = stableTableContentHash(for: source) - let occurrenceIndex = occurrenceByContentHash[contentHash, default: 0] - occurrenceByContentHash[contentHash] = occurrenceIndex + 1 + // Advance occurrence index even for active/out-of-scope tables so + // inactive duplicates keep stable sourceIDs. + let occurrenceIndex = occurrenceByContentHash[meta.hash, default: 0] + occurrenceByContentHash[meta.hash] = occurrenceIndex + 1 let isActive = ctx.activeTokenIndices.contains(idx) if isActive { @@ -59,20 +222,28 @@ extension MarkdownStyler { continue } + // Outside the restyle scope the anchor attrs would be clipped away + // at application time — skip the render lookup and anchor build + // (occurrence bookkeeping above already ran, keeping IDs stable). + if ctx.outsideScope(token.range) { continue } + // See renderTable: resolve table colors under the text view's real appearance. let renderAppearance = ctx.layoutBridge?.firstTextContainer?.textView?.effectiveAppearance ?? NSApp.effectiveAppearance - let image = renderTable( - parsed, - baseFont: ctx.baseFont, - theme: ctx.configuration.theme, - codeBackgroundColor: ctx.codeBackgroundColor, - latex: ctx.services.latex, - appearance: renderAppearance + // Cells wrap to the container width (Obsidian-style); the render + // only exceeds it when the per-column floors genuinely don't fit, + // in which case the scrollable overlay below takes over. + let containerWidth = effectiveContainerWidth(for: ctx) + let (image, rendered) = tableImage( + for: source, + parsed: parsed, + ctx: ctx, + appearance: renderAppearance, + availableWidth: containerWidth ) + if rendered { renderedCount += 1 } let imageBounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) // Wide tables → scrollable mode (NSScrollView overlay); narrow → collapsed. - let containerWidth = effectiveContainerWidth(for: ctx) let isWide = image.size.width > containerWidth + 0.5 let computedSourceID = stableTableSourceID( for: source, @@ -98,6 +269,11 @@ extension MarkdownStyler { attrs: &attrs ) } + if tableCount > 0 { + let ms = Double(DispatchTime.now().uptimeNanoseconds - tablesT0) / 1_000_000 + let metaMs = Double(metaNanos) / 1_000_000 + PerfTrace.note { "styleTables scanned=\(tableCount) tables (skipped=\(skippedCount)), re-rendered=\(renderedCount) NSImage in \(String(format: "%.2f", ms))ms (substring+meta=\(String(format: "%.2f", metaMs))ms)" } + } return attrs } @@ -268,7 +444,8 @@ extension MarkdownStyler { theme: MarkdownEditorTheme, codeBackgroundColor: NSColor, latex: any LatexRenderer, - appearance: NSAppearance + appearance: NSAppearance, + availableWidth: CGFloat ) -> NSImage { let columnCount = table.alignments.count let cellHPadding: CGFloat = 12 @@ -302,12 +479,39 @@ extension MarkdownStyler { } } - var columnWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) - var maxCellHeight: CGFloat = baseLineHeight + // CSS automatic table layout (W3C 17.5.2.2), which is what browser-based + // editors like Obsidian get for free: each column has a MAXIMUM width + // (content on one line) and a MINIMUM width (MCW — content may wrap but + // must not overflow, i.e. the widest unbreakable whitespace-separated + // segment). Measured segment-by-segment: a too-narrow boundingRect + // would emergency-break INSIDE words and understate the minimum. + func widestUnbreakableSegment(_ cell: NSAttributedString) -> CGFloat { + let str = cell.string as NSString + let whitespace = CharacterSet.whitespacesAndNewlines + var widest: CGFloat = 0 + var segStart = -1 + for i in 0...str.length { + let isBreak = i == str.length || { + guard let scalar = Unicode.Scalar(str.character(at: i)) else { return false } + return whitespace.contains(scalar) + }() + if isBreak { + if segStart >= 0 { + let segment = cell.attributedSubstring(from: NSRange(location: segStart, length: i - segStart)) + widest = max(widest, ceil(segment.size().width)) + segStart = -1 + } + } else if segStart < 0 { + segStart = i + } + } + return widest + } + var maxWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) + var minWidths = [CGFloat](repeating: minColumnContentWidth, count: columnCount) func considerCell(_ cell: NSAttributedString, col: Int) { - let size = cell.size() - columnWidths[col] = max(columnWidths[col], ceil(size.width)) - maxCellHeight = max(maxCellHeight, ceil(size.height)) + maxWidths[col] = max(maxWidths[col], ceil(cell.size().width)) + minWidths[col] = max(minWidths[col], widestUnbreakableSegment(cell)) } for (i, cell) in headerCells.enumerated() where i < columnCount { considerCell(cell, col: i) @@ -318,13 +522,56 @@ extension MarkdownStyler { } } - let lineHeight = max(baseLineHeight, maxCellHeight) + // Distribute the available width: + // - everything fits on one line → natural (maximum) widths; + // - too wide → shrink to the available width, but never below a + // column's longest unbreakable word; the slack above the minimums is + // distributed proportionally to each column's (max − min) stretch; + // - even the minimums don't fit (many-column tables) → columns stay at + // their minimums, the table renders wider than the container, and + // the horizontal-scroll overlay takes over as before. + let chrome = CGFloat(columnCount) * 2 * cellHPadding + + CGFloat(columnCount + 1) * borderWidth + let contentAvailable = availableWidth - chrome + let sumMax = maxWidths.reduce(0, +) + let sumMin = minWidths.reduce(0, +) + var columnWidths = maxWidths + if contentAvailable > 0, sumMax > contentAvailable { + if sumMin >= contentAvailable { + columnWidths = minWidths + } else { + let extra = contentAvailable - sumMin + let totalStretch = sumMax - sumMin + columnWidths = zip(minWidths, maxWidths).map { mn, mx in + mn + ((mx - mn) / totalStretch * extra).rounded(.down) + } + } + } + + // Per-row heights: each row is as tall as its tallest (wrapped) cell. + func cellHeight(_ cell: NSAttributedString, col: Int) -> CGFloat { + let bounds = cell.boundingRect( + with: NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin] + ) + return ceil(bounds.height) + } let rowCount = 1 + table.rows.count // header + body rows + var rowContentHeights = [CGFloat](repeating: baseLineHeight, count: rowCount) + for (i, cell) in headerCells.enumerated() where i < columnCount { + rowContentHeights[0] = max(rowContentHeights[0], cellHeight(cell, col: i)) + } + for (rowIdx, row) in bodyCells.enumerated() { + for (i, cell) in row.enumerated() where i < columnCount { + rowContentHeights[rowIdx + 1] = max(rowContentHeights[rowIdx + 1], cellHeight(cell, col: i)) + } + } + let totalWidth = columnWidths.reduce(0, +) + CGFloat(columnCount) * 2 * cellHPadding + CGFloat(columnCount + 1) * borderWidth - let rowHeight = lineHeight + 2 * cellVPadding - let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount + 1) * borderWidth + let totalHeight = rowContentHeights.reduce(0) { $0 + $1 + 2 * cellVPadding } + + CGFloat(rowCount + 1) * borderWidth let size = NSSize(width: totalWidth, height: totalHeight) @@ -337,7 +584,7 @@ extension MarkdownStyler { var rowTop = [CGFloat](repeating: 0, count: rowCount + 1) rowTop[0] = borderWidth for i in 0.. String? + /// Union bounds of the restyle's paragraph scope; nil = whole document + /// (initial load). Attribute application clips per paragraph anyway, + /// so the NSImage passes can skip tokens wholly outside these bounds + /// instead of walking every token in the document per keystroke. + var scopeBounds: (lo: Int, hi: Int)? = nil + /// Pre-classified per-kind token arrays; nil for direct callers (tests), + /// which fall back to classifying `tokens` on demand. + var classified: ClassifiedStyleTokens? = nil var services: MarkdownEditorServices { configuration.services } + + // Per-kind indexed arrays: the cached classification, or a one-off + // classification of `tokens` when a direct caller passed none. + var inlineLatexIndexed: [IndexedToken] { classified?.inlineLatex ?? Self.indexed(tokens, .inlineLatex) } + var blockLatexIndexed: [IndexedToken] { classified?.blockLatex ?? Self.indexed(tokens, .blockLatex) } + var imageEmbedIndexed: [IndexedToken] { classified?.imageEmbed ?? Self.indexed(tokens, .imageEmbed) } + var imageLinkIndexed: [IndexedToken] { classified?.imageLink ?? Self.indexed(tokens, .imageLink) } + var tableIndexed: [IndexedToken] { classified?.table ?? Self.indexed(tokens, .table) } + + static func indexed(_ tokens: [MarkdownToken], _ kind: MarkdownTokenKind) -> [IndexedToken] { + tokens.enumerated().compactMap { $0.element.kind == kind ? ($0.offset, $0.element) : nil } + } + + /// The slice of a location-sorted, non-overlapping per-kind array that + /// intersects the restyle scope — binary-searched so out-of-scope + /// tokens are never even visited. Whole array when scope is nil. + func scoped(_ arr: [IndexedToken]) -> ArraySlice { + guard let bounds = scopeBounds else { return arr[...] } + return MarkdownStyler.scopedSlice(arr, lo: bounds.lo, hi: bounds.hi) + } + + /// True when `range` lies entirely outside the restyle scope — its + /// attributes would be clipped away at application time. + func outsideScope(_ range: NSRange) -> Bool { + guard let scopeBounds else { return false } + return NSMaxRange(range) <= scopeBounds.lo || range.location >= scopeBounds.hi + } + + /// True when iteration (over location-sorted tokens) is past the scope. + func pastScope(_ range: NSRange) -> Bool { + guard let scopeBounds else { return false } + return range.location >= scopeBounds.hi + } + } + + /// Binary-searched slice of a location-sorted, non-overlapping per-kind + /// array whose tokens intersect `[lo, hi)` — tokens outside are never + /// visited. Shared by the styler's scope culling and the coordinator's + /// edit-scoped candidate collection (which used to walk each array + /// linearly from the document head to the edit). + static func scopedSlice(_ arr: [IndexedToken], lo bound: Int, hi upper: Int) -> ArraySlice { + var lo = 0, hi = arr.count + while lo < hi { // first NSMaxRange > bound + let m = (lo + hi) / 2 + if NSMaxRange(arr[m].token.range) > bound { hi = m } else { lo = m + 1 } + } + let start = lo + hi = arr.count + while lo < hi { // first location >= upper + let m = (lo + hi) / 2 + if arr[m].token.range.location >= upper { hi = m } else { lo = m + 1 } + } + return arr[start.., wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil }, precomputedTokens: [MarkdownToken]? = nil, + classified: ClassifiedStyleTokens? = nil, + precomputedBlocks: [Block]? = nil, scopedRanges: [NSRange]? = nil, configuration: MarkdownEditorConfiguration = .default ) -> [StyledRange] { let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST(in: text) let nsText = text as NSString - let codeTokens = tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } + let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in + let valid = ranges.filter { $0.location != NSNotFound && $0.length > 0 } + guard let lo = valid.map(\.location).min(), + let hi = valid.map({ NSMaxRange($0) }).max() else { return nil } + return (lo, hi) + } + let codeTokens = classified?.code ?? tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode } let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) let baseDefaultLineHeight = ceil( layoutBridge?.defaultLineHeight(for: baseFont) @@ -75,22 +159,30 @@ enum MarkdownStyler { latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize) ?? NSFont.systemFont(ofSize: hiddenMarkerSize), configuration: configuration, - wikiLinkIDProvider: wikiLinkIDProvider + wikiLinkIDProvider: wikiLinkIDProvider, + scopeBounds: scopeBounds, + classified: classified ) var result: [StyledRange] = [] // AST-native styler handles everything but NSImage rendering (incl. the composition fixes). + let astT0 = DispatchTime.now().uptimeNanoseconds result += MarkdownASTStyler.styleAttributes( text: text, fontName: fontName, fontSize: fontSize, caretLocation: caretLocation, wikiLinkIDProvider: wikiLinkIDProvider, - scopedRanges: scopedRanges, configuration: configuration + scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks, + configuration: configuration ) + let astMs = Double(DispatchTime.now().uptimeNanoseconds - astT0) / 1_000_000 // NSImage rendering reuses the existing, proven machinery. + let imgT0 = DispatchTime.now().uptimeNanoseconds result += styleBlockLatex(ctx) result += styleInlineLatex(ctx) result += styleImageEmbeds(ctx) result += styleImageLinks(ctx) + let imgMs = Double(DispatchTime.now().uptimeNanoseconds - imgT0) / 1_000_000 result += styleTables(ctx) + PerfTrace.note { " styleAttributes: ast=\(String(format: "%.2f", astMs))ms latex+img4=\(String(format: "%.2f", imgMs))ms styledRanges=\(result.count)" } return result } } diff --git a/Sources/MarkdownEngine/Styling/TextStylingService.swift b/Sources/MarkdownEngine/Styling/TextStylingService.swift index 1795a624..cef42300 100644 --- a/Sources/MarkdownEngine/Styling/TextStylingService.swift +++ b/Sources/MarkdownEngine/Styling/TextStylingService.swift @@ -55,6 +55,8 @@ struct TextStylingService { activeTokenIndices: Set, wikiLinkIDProvider: @escaping (NSRange) -> String?, precomputedTokens: [MarkdownToken]? = nil, + classified: MarkdownStyler.ClassifiedStyleTokens? = nil, + precomputedBlocks: [Block]? = nil, configuration: MarkdownEditorConfiguration = .default ) { let paragraphs = normalize(paragraphCandidates) @@ -70,6 +72,7 @@ struct TextStylingService { return } + let styleT0 = DispatchTime.now().uptimeNanoseconds let styledRanges = MarkdownStyler.styleAttributes( text: textView.string, fontName: baseFont.fontName, @@ -79,10 +82,14 @@ struct TextStylingService { activeTokenIndices: activeTokenIndices, wikiLinkIDProvider: wikiLinkIDProvider, precomputedTokens: precomputedTokens, + classified: classified, + precomputedBlocks: precomputedBlocks, scopedRanges: paragraphs, configuration: configuration ) + let styleMs = Double(DispatchTime.now().uptimeNanoseconds - styleT0) / 1_000_000 + let spellT0 = DispatchTime.now().uptimeNanoseconds let spellingDisabledRanges = styledRanges.compactMap { (range, attrs) -> NSRange? in attrs[.spellingState] as? Int == 0 ? range : nil } @@ -96,6 +103,8 @@ struct TextStylingService { for disabledRange in spellingDisabledRanges { textView.textStorage?.addAttribute(.spellingState, value: 0, range: disabledRange) } + let spellMs = Double(DispatchTime.now().uptimeNanoseconds - spellT0) / 1_000_000 + let attrT0 = DispatchTime.now().uptimeNanoseconds for paragraph in paragraphs { textView.textStorage?.setAttributes([ .font: baseFont, @@ -111,18 +120,24 @@ struct TextStylingService { } } textView.textStorage?.endEditing() + let attrMs = Double(DispatchTime.now().uptimeNanoseconds - attrT0) / 1_000_000 // No ensureLayout here: + let evlT0 = DispatchTime.now().uptimeNanoseconds textView.setNeedsDisplay(textView.visibleRect) (textView as? NativeTextView)?.ensureVisibleLayout() + let evlMs = Double(DispatchTime.now().uptimeNanoseconds - evlT0) / 1_000_000 + PerfTrace.note { " restyle split: styleAttrs=\(String(format: "%.2f", styleMs))ms spell=\(String(format: "%.2f", spellMs))ms attrApply(paras=\(paragraphs.count))=\(String(format: "%.2f", attrMs))ms ensureVisLayout=\(String(format: "%.2f", evlMs))ms" } } private static func normalize(_ candidates: [NSRange]) -> [NSRange] { + // Exact-duplicate drop in one pass (was O(n²) via contains); order and + // overlapping-but-unequal ranges are preserved exactly as before. + var seen = Set() + seen.reserveCapacity(candidates.count) var result: [NSRange] = [] for candidate in candidates where candidate.location != NSNotFound && candidate.length > 0 { - if result.contains(where: { $0.location == candidate.location && $0.length == candidate.length }) { - continue - } - result.append(candidate) + let key = candidate.location &* 1_000_003 &+ candidate.length + if seen.insert(key).inserted { result.append(candidate) } } return result } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift index 15eaeb6d..d546ef85 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Autocorrect.swift @@ -82,7 +82,7 @@ extension NativeTextViewCoordinator { func isInsideSpellcheckSuppressedToken(location: Int, in text: String) -> Bool { let parsed = parsedDocument(for: text) return parsed.tokens.contains { token in - guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed else { + guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed || token.kind == .table else { return false } return NSLocationInRange(location, token.range) @@ -92,7 +92,7 @@ extension NativeTextViewCoordinator { func isInsideSpellcheckSuppressedToken(range: NSRange, in text: String) -> Bool { let parsed = parsedDocument(for: text) return parsed.tokens.contains { token in - guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed else { + guard token.kind == .wikiLink || token.kind == .link || token.kind == .imageEmbed || token.kind == .table else { return false } return NSIntersectionRange(token.range, range).length > 0 diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 756c2de9..360c0c18 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -13,16 +13,16 @@ import AppKit extension NativeTextViewCoordinator { - func updateCodeBlockSelection(textView: NSTextView, tokens: [MarkdownToken]? = nil) { + func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { guard let textContainer = textView.textContainer else { onCodeBlockSelectionChange?([]) return } - if let tokens = tokens { - cachedCodeBlockTokens = tokens.enumerated() - .filter { $0.element.kind == .codeBlock } - .map { (index: $0.offset, token: $0.element) } + if let parsed { + // Indexed pairs come from the parse's single classification pass — + // no per-call full-token filter. + cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices } else if cachedCodeBlockTokens.isEmpty { onCodeBlockSelectionChange?([]) return @@ -31,14 +31,50 @@ extension NativeTextViewCoordinator { let nsText = textView.string as NSString let scrollOffset = textView.enclosingScrollView?.contentView.bounds.origin ?? .zero + // Identical inputs → identical selections. The delegate path calls + // this twice per keystroke (selection change + textDidChange) and on + // every caret move; skip the substring/language/viewRect work when + // nothing relevant changed. Calls without `parsed` (document switch, + // scroll hooks) always recompute. + // + // The key must include the FULL active-token set, not just its code + // intersection: a caret move INTO a standalone block (block-LaTeX / + // table) toggles that block between its rendered image and raw source + // — a real height change that shifts a code block below it to a new Y + // — while leaving version, scroll, width, and the code∩active set + // unchanged. Keying on the whole active set makes any such toggle + // (the only same-version event that moves layout) recompute. Text + // edits are covered by the bumped version; the twice-per-keystroke + // redundancy still dedupes because both calls share one active set. + if let parsed { + let key = (parsed.version, scrollOffset.y, textContainer.containerSize.width, + activeTokenIndices) + if let last = lastCodeSelKey, last == key { return } + lastCodeSelKey = key + } else { + lastCodeSelKey = nil + } + // One-shot full-document layout per document; fixes stale Y from TextKit 2's lazy layout without per-update cost. if !didEnsureLayoutForCurrentDocument, let tlm = textView.textLayoutManager { tlm.ensureLayout(for: tlm.documentRange) didEnsureLayoutForCurrentDocument = true } + // Only on-screen code blocks have a visible copy button. Computing a + // viewRect (and copying the code) for every block in the document is + // O(doc) and dominates large files; cull to the laid-out viewport range + // — scroll hooks recompute as blocks come into view. + let visibleRange: NSRange? = { + guard let tlm = textView.textLayoutManager, + let vp = tlm.textViewportLayoutController.viewportRange else { return nil } + let start = tlm.offset(from: tlm.documentRange.location, to: vp.location) + return NSRange(location: start, length: tlm.offset(from: vp.location, to: vp.endLocation)) + }() + let selections: [CodeBlockSelection] = cachedCodeBlockTokens.compactMap { originalIndex, token in guard !activeTokenIndices.contains(originalIndex) else { return nil } + if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil } guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil } boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 0246f4ec..107af51d 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -35,9 +35,19 @@ extension NativeTextViewCoordinator { if textView.string != displayText { textView.string = displayText + parseGeneration &+= 1 } lastSyncedText = text + lastComputedStorage = text + previousDisplayLength = (displayText as NSString).length let nsDisplay = displayText as NSString + // Fresh document baseline: drop the incremental parse state and reseed + // the backtick census (a stale count from the previous document would + // force a spurious full-document restyle on the first keystroke). + parseState.invalidate() + pendingBacktickWindow = nil + backtickCensusNeedsRescan = false + previousBacktickCount = MarkdownDetection.tripleBacktickCount(in: nsDisplay) let fullRange = NSRange(location: 0, length: nsDisplay.length) let (baseFont, paragraph) = TextStylingService.makeBaseFontAndStyle( @@ -59,12 +69,13 @@ extension NativeTextViewCoordinator { // Base attributes only — the source stays verbatim and unstyled. activeTokenIndices = [] } else { - let tokens = parsedDocument(for: displayText).tokens + let parsed = parsedDocument(for: displayText) + let tokens = parsed.tokens // Hide caret from styling when read-only, else clicks reveal raw token syntax. let caretLocation = textView.isEditable ? textView.selectedRange().location : -1 - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: tokens, + activeTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), in: nsDisplay, suppressed: !textView.isEditable ) @@ -82,6 +93,7 @@ extension NativeTextViewCoordinator { // wikiLinkMetadata was just refreshed by makeDisplayState above, so ranges match here. wikiLinkIDProvider: { [weak self] range in self?.wikiLinkID(for: range) }, precomputedTokens: tokens, + classified: parsed.classified, configuration: configuration ) for (range, attrs) in ranges { @@ -116,7 +128,9 @@ extension NativeTextViewCoordinator { func restyleTextView( _ textView: NSTextView, paragraphCandidates: [NSRange], - tokens: [MarkdownToken]? = nil + tokens: [MarkdownToken]? = nil, + classified: MarkdownStyler.ClassifiedStyleTokens? = nil, + blocks: [Block]? = nil ) { // Raw mode: no restyling; typing keeps base attrs via the typing shim. guard !configuration.rawSourceMode else { return } @@ -139,6 +153,8 @@ extension NativeTextViewCoordinator { self?.wikiLinkID(for: range) }, precomputedTokens: tokens, + classified: classified, + precomputedBlocks: blocks, configuration: configuration ) // Reconcile wide-table overlays after layout settles. @@ -149,53 +165,111 @@ extension NativeTextViewCoordinator { } } - func parsedDocument(for text: String) -> ParsedDocument { - if cachedParsedText == text, let cachedParsedDocument { - return cachedParsedDocument + func parsedDocument(for text: String, edit: ParseEditDescriptor? = nil) -> ParsedDocument { + let length = (text as NSString).length + if let cachedParsedDocument, cachedParsedLength == length { + // O(1) hit: nothing has edited the storage since the cached parse. + if cachedParseGeneration == parseGeneration { return cachedParsedDocument } + // Generation moved but the text may still be identical (e.g. an + // attribute-only pass): confirm via NSString.isEqual (a byte + // compare — the bridged Swift `==` walked 139k chars per keystroke). + if let cachedParsedText, (cachedParsedText as NSString).isEqual(to: text) { + cachedParseGeneration = parseGeneration + return cachedParsedDocument + } } - let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + let tokens = parseState.tokens(for: text, edit: edit) + let tClassify = DispatchTime.now().uptimeNanoseconds var codeTokens: [MarkdownToken] = [] var latexTokens: [MarkdownToken] = [] var blockLatexTokens: [MarkdownToken] = [] var wikiLinkTokens: [MarkdownToken] = [] var imageEmbedTokens: [MarkdownToken] = [] + var tableTokens: [MarkdownToken] = [] + var codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] = [] + var inlineLatexIdx: [(index: Int, token: MarkdownToken)] = [] + var blockLatexIdx: [(index: Int, token: MarkdownToken)] = [] + var imageEmbedIdx: [(index: Int, token: MarkdownToken)] = [] + var imageLinkIdx: [(index: Int, token: MarkdownToken)] = [] + var tableIdx: [(index: Int, token: MarkdownToken)] = [] codeTokens.reserveCapacity(tokens.count / 2) latexTokens.reserveCapacity(tokens.count / 4) blockLatexTokens.reserveCapacity(tokens.count / 4) wikiLinkTokens.reserveCapacity(tokens.count / 4) - for token in tokens { + for (index, token) in tokens.enumerated() { switch token.kind { case .codeBlock, .inlineCode: codeTokens.append(token) + if token.kind == .codeBlock { + codeBlockTokensWithIndices.append((index, token)) + } case .inlineLatex: latexTokens.append(token) + inlineLatexIdx.append((index, token)) case .blockLatex: blockLatexTokens.append(token) + blockLatexIdx.append((index, token)) case .wikiLink: wikiLinkTokens.append(token) case .imageEmbed: imageEmbedTokens.append(token) + imageEmbedIdx.append((index, token)) + case .imageLink: + imageLinkIdx.append((index, token)) + case .table: + tableTokens.append(token) + tableIdx.append((index, token)) default: break } } + parsedDocumentVersion &+= 1 let parsed = ParsedDocument( tokens: tokens, + blocks: parseState.currentBlocks, codeTokens: codeTokens, latexTokens: latexTokens, blockLatexTokens: blockLatexTokens, wikiLinkTokens: wikiLinkTokens, - imageEmbedTokens: imageEmbedTokens + imageEmbedTokens: imageEmbedTokens, + tableTokens: tableTokens, + codeBlockTokensWithIndices: codeBlockTokensWithIndices, + classified: MarkdownStyler.ClassifiedStyleTokens( + inlineLatex: inlineLatexIdx, blockLatex: blockLatexIdx, + imageEmbed: imageEmbedIdx, imageLink: imageLinkIdx, + table: tableIdx, code: codeTokens), + version: parsedDocumentVersion ) cachedParsedText = text + cachedParsedLength = length + cachedParseGeneration = parseGeneration cachedParsedDocument = parsed + PerfTrace.note { + let ms = Double(DispatchTime.now().uptimeNanoseconds - tClassify) / 1_000_000 + return "classify=\(String(format: "%.2f", ms))ms #tokens=\(tokens.count)" + } return parsed } + /// Memoized computeActiveTokenIndices — a pure function of + /// (parsed.version, selection, suppressed) that otherwise runs up to + /// three times per keystroke on identical inputs (pre-edit ask, + /// selection change, textDidChange). + func activeTokenIndices(parsed: ParsedDocument, selection: NSRange, in text: NSString, suppressed: Bool) -> Set { + if let memo = activeTokenMemo, memo.version == parsed.version, + memo.selection == selection, memo.suppressed == suppressed { + return memo.result + } + let result = MarkdownDetection.computeActiveTokenIndices( + selectionRange: selection, tokens: parsed.tokens, in: text, suppressed: suppressed) + activeTokenMemo = (parsed.version, selection, suppressed, result) + return result + } + func paragraphRanges( in text: NSString, intersecting editedRange: NSRange @@ -248,16 +322,18 @@ extension NativeTextViewCoordinator { } func restyleParagraphs(_ paragraphs: [NSRange], in textView: NSTextView) { - let parsed = parsedDocument(for: textView.string) + let docText = textView.string // one O(doc) bridge, reused below + let parsed = parsedDocument(for: docText) let tokens = parsed.tokens - let nsText = textView.string as NSString - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: tokens, + let nsText = docText as NSString + activeTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), in: nsText, suppressed: !textView.isEditable ) - restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens) + restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens, + classified: parsed.classified, blocks: parsed.blocks) } func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index a52985f9..7e4214d6 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -70,6 +70,7 @@ extension NativeTextViewCoordinator { public func textDidChange(_ notification: Notification) { guard let tv = notification.object as? NSTextView else { return } + PerfTrace.checkpoint("didIn") // Before the early returns: the first keystroke must hide the placeholder. (tv as? NativeTextView)?.refreshPlaceholderVisibility() // Raw mode: display IS storage — sync the binding, skip the restyle. @@ -103,18 +104,72 @@ extension NativeTextViewCoordinator { let rawSelRange = tv.selectedRange() - let fullLength = (tv.string as NSString).length + let docString = tv.string + let fullText = docString as NSString + let fullLength = fullText.length guard !tv.hasMarkedText() else { return } let safeLocation = min(rawSelRange.location, fullLength) let safeSelRange = NSRange(location: safeLocation, length: 0) previousCaretLocation = safeSelRange.location + PerfTrace.begin(docLength: fullLength) + + // Edit descriptor, hoisted above the wiki sync so both it and the + // paragraph scoping below share it. + let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange + pendingEditedRange = nil + // Exactly one proposed edit since the last completed cycle means the + // descriptor describes THIS transition; anything else (interceptor + // substitutions, IME commits, WT batches) distrusts the fast paths. + let singleTrackedEdit = pendingEditCount == 1 + pendingEditCount = 0 +#if DEBUG + debugLastEditWasTrusted = singleTrackedEdit +#endif + let lengthDelta = previousDisplayLength >= 0 ? fullLength - previousDisplayLength : Int.min + previousDisplayLength = fullLength + + // Parse-cache generation. shouldChangeTextIn already bumped for this + // mutation and the selection-change re-parsed the post-edit text at + // that generation; bumping again would force parsedDocument onto its + // O(doc) byte-compare VERIFY. A trusted length-CHANGING edit already + // invalidated the pre-edit cache by length, so keep the generation and + // hit O(1). Same-length/untracked edits still bump — the byte-compare + // then catches a same-length content change the length check misses. + if !(singleTrackedEdit && lengthDelta != 0 && lengthDelta != Int.min) { + parseGeneration &+= 1 + } + if !wtActive { - let storageState = WikiLinkService.makeStorageState( - from: tv.string, - existingMetadata: self.wikiLinkMetadata, - textStorage: tv.textStorage - ) + let storageState = PerfTrace.measure("wiki") { + WikiLinkService.updatedStorageState( + displayText: docString, + editedRange: editedRange, + changeInLength: lengthDelta, + previousStorage: lastComputedStorage, + previousMetadata: wikiLinkMetadata + ) ?? WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, + textStorage: tv.textStorage + ) + } self.wikiLinkMetadata = storageState.metadata + self.lastComputedStorage = storageState.storage +#if DEBUG + // Sampled safety net: every 64th keystroke, prove the splice equals + // a full rebuild. Opt-in (MD_PERF_VERIFY=1) — the O(doc) rebuild + // spikes pollute the PERF numbers. Remove with PerfTrace after sign-off. + wikiVerifyCounter &+= 1 + if PerfTrace.verifyEnabled, wikiVerifyCounter % 64 == 0 { + let reference = WikiLinkService.makeStorageState( + from: docString, + existingMetadata: wikiLinkMetadata, + textStorage: tv.textStorage + ) + assert(reference.storage == storageState.storage, + "wiki incremental splice diverged from full rebuild") + } +#endif if storageState.storage != self.lastSyncedText { DispatchQueue.main.async { self.lastSyncedText = storageState.storage @@ -123,7 +178,6 @@ extension NativeTextViewCoordinator { } } - let fullText = tv.string as NSString let paragraphRange = fullText.paragraphRange(for: safeSelRange) let documentLength = fullText.length let nextLocation = min(documentLength, NSMaxRange(paragraphRange)) @@ -133,8 +187,6 @@ extension NativeTextViewCoordinator { let nextParagraph = nextLocation < documentLength ? fullText.paragraphRange(for: NSRange(location: nextLocation, length: 0)) : NSRange(location: NSNotFound, length: 0) - let editedRange = pendingEditedRange ?? tv.textStorage?.editedRange ?? safeSelRange - pendingEditedRange = nil let wtEditedFallback: NSRange? = { guard wtActive, let sel = wtInitialSelectionRange else { return nil } let docLength = fullText.length @@ -153,11 +205,18 @@ extension NativeTextViewCoordinator { nextParagraph ] + editedParagraphs - let backtickCount = tv.string.components(separatedBy: "```").count - 1 + let backtickCount = PerfTrace.measure("backtick") { + incrementalBacktickCensus(fullText: fullText, editedRange: editedRange, + lengthDelta: lengthDelta, trusted: singleTrackedEdit) + } let codeBlockStructureChanged = backtickCount != previousBacktickCount previousBacktickCount = backtickCount - let parsed = parsedDocument(for: tv.string) + let parsed = PerfTrace.measure("parse") { + parsedDocument(for: docString, edit: singleTrackedEdit + ? ParseEditDescriptor(editedRange: editedRange, delta: lengthDelta) + : nil) + } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens @@ -165,12 +224,9 @@ extension NativeTextViewCoordinator { let preEditActiveTokenIndices = pendingPreEditActiveTokenIndices ?? previousActiveTokenIndices pendingPreEditActiveTokenIndices = nil - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: safeSelRange, - tokens: tokens, - in: fullText, - suppressed: !tv.isEditable - ) + activeTokenIndices = PerfTrace.measure("activeTok") { + activeTokenIndices(parsed: parsed, selection: safeSelRange, in: fullText, suppressed: !tv.isEditable) + } filterImageEmbedActiveTokens(parsed: parsed, text: fullText, selectionLocation: safeSelRange.location) updateAutocorrectSettings( tv, @@ -184,17 +240,35 @@ extension NativeTextViewCoordinator { if codeBlockStructureChanged { effectiveParagraphCandidates = [NSRange(location: 0, length: fullText.length)] } - // Always restyle paragraphs containing latex/imageEmbed tokens to avoid stale raw text. - let latexParagraphs = (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { fullText.paragraphRange(for: $0.range) } + // Restyle only latex/imageEmbed paragraphs the EDIT touches (mirrors the + // table loop below); the caret entering/leaving a formula, which flips + // rendered↔raw, is covered by tokenRestyleParagraphs. Blanket-restyling + // every such paragraph made scopeBounds span the whole document, which + // defeated the per-pass scope culling in the styler. + let latexParagraphs = PerfTrace.measure("latexMap") { () -> [NSRange] in + var out: [NSRange] = [] + // Binary-searched slices — the old loops walked each array from + // the document head to the edit on every keystroke. + for group in [parsed.classified.inlineLatex, parsed.classified.blockLatex, parsed.classified.imageEmbed] { + for (_, token) in MarkdownStyler.scopedSlice(group, lo: safeEditedRange.location, hi: NSMaxRange(safeEditedRange)) + where NSIntersectionRange(token.range, safeEditedRange).length > 0 { + out.append(fullText.paragraphRange(for: token.range)) + } + } + return out + } effectiveParagraphCandidates.append(contentsOf: latexParagraphs) // A table renders as ONE image anchored on the block's FIRST paragraph. // When an edit touches any of its rows (typing in a row, or a paste // that merges into an existing table), the styler re-emits the anchor // against the FULL block — restyling only the edited rows would clip // that anchor away and the table goes blank until a full restyle. - let editedTableParagraphs = tokens - .filter { $0.kind == .table && NSIntersectionRange($0.range, safeEditedRange).length > 0 } - .map { fullText.paragraphRange(for: $0.range) } + // Location-sorted classified tables, binary-searched to the edit. + var editedTableParagraphs: [NSRange] = [] + for (_, token) in MarkdownStyler.scopedSlice(parsed.classified.table, lo: safeEditedRange.location, hi: NSMaxRange(safeEditedRange)) + where NSIntersectionRange(token.range, safeEditedRange).length > 0 { + editedTableParagraphs.append(fullText.paragraphRange(for: token.range)) + } effectiveParagraphCandidates.append(contentsOf: editedTableParagraphs) effectiveParagraphCandidates.append(contentsOf: tokenRestyleParagraphs( in: fullText, @@ -203,18 +277,22 @@ extension NativeTextViewCoordinator { previousActiveTokenIndices: preEditActiveTokenIndices )) - restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens) - updateCodeBlockSelection(textView: tv, tokens: tokens) + PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) } + PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } if wtActive { previousActiveTokenIndices = activeTokenIndices + PerfTrace.end() return } - if let bottomTextView = tv as? NativeTextView, - let scrollView = tv.enclosingScrollView { - bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") - (scrollView as? ClampedScrollView)?.clampToInsets() + PerfTrace.measure("overscroll") { + if let bottomTextView = tv as? NativeTextView, + let scrollView = tv.enclosingScrollView { + bottomTextView.recalcOverscroll(for: scrollView, debugTag: "textDidChange") + (scrollView as? ClampedScrollView)?.clampToInsets() + } } previousActiveTokenIndices = activeTokenIndices + PerfTrace.end() } public func textViewDidChangeSelection(_ notification: Notification) { @@ -222,30 +300,51 @@ extension NativeTextViewCoordinator { // Raw mode: plain source — no reveal, snap-back, or inline previews. if configuration.rawSourceMode { return } if isWritingToolsActive { return } + PerfTrace.checkpoint("selIn") + defer { PerfTrace.checkpoint("selOut") } let selRange = tv.selectedRange() let currentEventType = NSApp.currentEvent?.type + // ONE bridge of the document text — this handler fires on every + // keystroke (mid-edit) and every caret move, and used to re-copy + // `tv.string` (O(doc) each) at a dozen separate sites below. The + // snap-back branch mutates the text and re-reads explicitly. + let docText = tv.string + let nsText = docText as NSString // Mouse-/Wake-Fokus auf Link: kein Preview, erst Navigation. Gilt für alle Nicht-Key-Events. if currentEventType != .keyDown, - selRange.location < (tv.string as NSString).length, + selRange.location < nsText.length, tv.textStorage?.attribute(.link, at: selRange.location, effectiveRange: nil) != nil { isImageEmbedActive = false isWikiLinkActive = false onInlineSelectionChange?(nil) return } - updateSelectionStates(tv) + PerfTrace.measure("selStates") { updateSelectionStates(tv, nsText: nsText) } let selLoc = selRange.location - let parsed = parsedDocument(for: tv.string) + // Selection change fires BEFORE textDidChange mid-edit: hand the + // pending descriptor through so this (the keystroke's first post-edit + // parse) splices in O(edit) instead of scanning the whole document. + let selectionEdit: ParseEditDescriptor? = { + guard let pending = pendingEditedRange, pendingEditCount == 1, + previousDisplayLength >= 0 else { return nil } + let delta = nsText.length - previousDisplayLength + return ParseEditDescriptor(editedRange: pending, delta: delta) + }() + // The keystroke's FIRST post-edit parse happens here, not in + // textDidChange (whose "parse" span then O(1)-hits) — measure it so + // the printed frame stops understating the real parse cost. + let parsed = PerfTrace.measure("selParse") { parsedDocument(for: docText, edit: selectionEdit) } let tokens = parsed.tokens let codeTokens = parsed.codeTokens let latexTokens = parsed.latexTokens let blockLatexTokens = parsed.blockLatexTokens - let nsText = tv.string as NSString let prevActive = activeTokenIndices - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices(selectionRange: selRange, tokens: tokens, in: nsText, suppressed: !tv.isEditable) - filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) + PerfTrace.measure("selActive") { + activeTokenIndices = activeTokenIndices(parsed: parsed, selection: selRange, in: nsText, suppressed: !tv.isEditable) + filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) + } // Snap-back: when the caret LEFT a wiki/image token, re-sync its displayed name to the live target name. if selRange.length == 0, @@ -283,35 +382,18 @@ extension NativeTextViewCoordinator { } } - updateAutocorrectSettings( - tv, - caretLocation: selLoc, - codeTokens: codeTokens, - latexTokens: latexTokens, - allTokens: tokens - ) + PerfTrace.measure("selAuto") { + updateAutocorrectSettings( + tv, + caretLocation: selLoc, + codeTokens: codeTokens, + latexTokens: latexTokens, + allTokens: tokens + ) + } let caretLoc = selRange.location let paragraphRange = nsText.paragraphRange(for: NSRange(location: caretLoc, length: 0)) - var paragraphCandidates: [NSRange] = [paragraphRange] - if paragraphRange.length == 0 && caretLoc > 0 { - paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: max(0, caretLoc - 1), length: 0))) - } - if let prevLoc = previousCaretLocation, prevLoc != caretLoc { - let safePrev = min(prevLoc, nsText.length) - let prevPara = nsText.paragraphRange(for: NSRange(location: safePrev, length: 0)) - paragraphCandidates.append(prevPara) - } - // Also restyle paragraphs containing latex/imageEmbed tokens to refresh rendering. - let latexParagraphs = (latexTokens + blockLatexTokens + parsed.imageEmbedTokens).map { nsText.paragraphRange(for: $0.range) } - paragraphCandidates.append(contentsOf: latexParagraphs) - paragraphCandidates.append(contentsOf: tokenRestyleParagraphs( - in: nsText, - tokens: tokens, - currentActiveTokenIndices: activeTokenIndices, - previousActiveTokenIndices: previousActiveTokenIndices - )) - let shouldSkipSelectionRestyle = pendingEditedRange != nil let tokensChanged = activeTokenIndices != prevActive // Caret crossings in/out of `- [ ]` syntax need a restyle too: task @@ -321,9 +403,9 @@ extension NativeTextViewCoordinator { // cursor-out (after editing the brackets) leaves the line stuck on // raw chars. let prevTaskSyntax = previousCaretLocation.flatMap { - MarkdownStyler.taskSyntaxRange(at: $0, in: tv.string) + MarkdownStyler.taskSyntaxRange(at: $0, in: docText) } - let currentTaskSyntax = MarkdownStyler.taskSyntaxRange(at: selLoc, in: tv.string) + let currentTaskSyntax = MarkdownStyler.taskSyntaxRange(at: selLoc, in: docText) let taskSyntaxChanged = prevTaskSyntax?.location != currentTaskSyntax?.location || prevTaskSyntax?.length != currentTaskSyntax?.length // Caret crossings in/out of a thematic-break (HR) line also need a @@ -332,16 +414,16 @@ extension NativeTextViewCoordinator { // `---` / `***` / `___` line. Without this, clicking on a rendered // HR wouldn't reveal the source dashes for editing. let prevHRLine = previousCaretLocation.flatMap { - MarkdownStyler.hrLineRange(at: $0, in: tv.string) + MarkdownStyler.hrLineRange(at: $0, in: docText) } - let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: tv.string) + let currentHRLine = MarkdownStyler.hrLineRange(at: selLoc, in: docText) let hrLineChanged = prevHRLine?.location != currentHRLine?.location || prevHRLine?.length != currentHRLine?.length // Bullet markers: caret in/out of `- ` syntax flips glyph ↔ raw. let prevBulletSyntax = previousCaretLocation.flatMap { - MarkdownStyler.bulletSyntaxRange(at: $0, in: tv.string) + MarkdownStyler.bulletSyntaxRange(at: $0, in: docText) } - let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: tv.string) + let currentBulletSyntax = MarkdownStyler.bulletSyntaxRange(at: selLoc, in: docText) let bulletSyntaxChanged = prevBulletSyntax?.location != currentBulletSyntax?.location || prevBulletSyntax?.length != currentBulletSyntax?.length // Mid-drag restyle is suppressed (revealing markers shifts the layout → drag hit-test lands short, dropping trailing chars) and replayed on release. @@ -352,7 +434,40 @@ extension NativeTextViewCoordinator { needsRestyleAfterDrag = true } else if tokensChanged || taskSyntaxChanged || hrLineChanged || bulletSyntaxChanged || needsRestyleAfterDrag { needsRestyleAfterDrag = false - restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens) + // Candidates are built ONLY when a restyle actually runs — this + // used to happen unconditionally on every selection change, + // including the mid-keystroke one that skips the restyle above. + var paragraphCandidates: [NSRange] = [paragraphRange] + if paragraphRange.length == 0 && caretLoc > 0 { + paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: max(0, caretLoc - 1), length: 0))) + } + if let prevLoc = previousCaretLocation, prevLoc != caretLoc { + let safePrev = min(prevLoc, nsText.length) + paragraphCandidates.append(nsText.paragraphRange(for: NSRange(location: safePrev, length: 0))) + } + // Latex/imageEmbed tokens only inside the caret/previous-caret + // paragraphs (binary-searched); the rendered↔raw flip of a token + // the caret entered or left is covered by tokenRestyleParagraphs. + // The old blanket map over EVERY formula in the document widened + // scopeBounds to the whole document on every caret-move restyle, + // defeating the styler's per-pass culling — O(#formulas) each. + let scopeLo = paragraphCandidates.map(\.location).min() ?? 0 + let scopeHi = paragraphCandidates.map { NSMaxRange($0) }.max() ?? 0 + for group in [parsed.classified.inlineLatex, parsed.classified.blockLatex, parsed.classified.imageEmbed] { + for (_, token) in MarkdownStyler.scopedSlice(group, lo: scopeLo, hi: scopeHi) { + paragraphCandidates.append(nsText.paragraphRange(for: token.range)) + } + } + paragraphCandidates.append(contentsOf: tokenRestyleParagraphs( + in: nsText, + tokens: tokens, + currentActiveTokenIndices: activeTokenIndices, + previousActiveTokenIndices: previousActiveTokenIndices + )) + PerfTrace.measure("selRestyle") { + restyleTextView(tv, paragraphCandidates: paragraphCandidates, tokens: tokens, + classified: parsed.classified, blocks: parsed.blocks) + } } // Auto-select content when clicking (mouse) into a rendered (previously inactive) latex or image embed @@ -375,14 +490,17 @@ extension NativeTextViewCoordinator { } } - let nsString = tv.string as NSString + // Text unchanged past this point (the snap-back branch returned above); + // only the selection may have moved. let selLocation = tv.selectedRange().location - let inlineContext = inlineTokenContext( - at: selLocation, - parsed: parsed, - codeTokens: codeTokens, - text: nsText - ) + let inlineContext = PerfTrace.measure("selCtx") { + inlineTokenContext( + at: selLocation, + parsed: parsed, + codeTokens: codeTokens, + text: nsText + ) + } let isInsideImageEmbed = { guard case .imageEmbed = inlineContext else { return false } return true @@ -406,14 +524,14 @@ extension NativeTextViewCoordinator { // no longer lives in the editor text — it sits in the `.wikiLinkID` side-channel. let placeholder: String if case .imageEmbed(let token) = inlineContext { - let embedName = nsString.substring(with: token.contentRange) + let embedName = nsText.substring(with: token.contentRange) if let suffix = wikiLinkID(for: token.range), !suffix.isEmpty { placeholder = "![[\(embedName)|\(suffix)]]" } else { placeholder = "![[\(embedName)]]" } } else { - placeholder = nsString.substring(with: displayRange) + placeholder = nsText.substring(with: displayRange) } let storageRange = inlineContext.selectionKind == .wikiLink ? storageRange(containingDisplayLocation: selLocation) ?? storageRange(forDisplayRange: displayRange) @@ -451,54 +569,127 @@ extension NativeTextViewCoordinator { // Skip during a pending edit — viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling. if !shouldSkipSelectionRestyle { - updateCodeBlockSelection(textView: tv, tokens: tokens) + updateCodeBlockSelection(textView: tv, parsed: parsed) } } + /// Backtick census in O(edit window): the greedy ``` count equals + /// Σ floor(runLen/3) over maximal backtick runs, so an edit only changes + /// the contribution of runs it touches. `previousBacktickCount` minus the + /// pre-edit window count (captured in shouldChangeTextIn) plus the + /// post-edit window count is exact. Any doubt → full scan. + private func incrementalBacktickCensus(fullText: NSString, editedRange: NSRange, + lengthDelta: Int, trusted: Bool) -> Int { + defer { pendingBacktickWindow = nil } + guard trusted, !backtickCensusNeedsRescan, + let base = pendingBacktickWindow, + lengthDelta != Int.min, + base.location == editedRange.location, + editedRange.length - lengthDelta == base.oldLength, + editedRange.location >= 0, + NSMaxRange(editedRange) <= fullText.length + else { + backtickCensusNeedsRescan = false + return MarkdownDetection.tripleBacktickCount(in: fullText) + } + let newWindow = MarkdownDetection.backtickWindowCount(in: fullText, around: editedRange) + let count = previousBacktickCount - base.oldCount + newWindow +#if DEBUG + // Opt-in (MD_PERF_VERIFY=1): the full scan is the O(doc) cost this + // census exists to avoid — as a default-on sample it skews the numbers. + backtickVerifyCounter &+= 1 + if PerfTrace.verifyEnabled, backtickVerifyCounter % 64 == 0 { + assert(count == MarkdownDetection.tripleBacktickCount(in: fullText), + "incremental backtick census diverged from the full scan") + } +#endif + return count + } + public func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { + // ONE bridge of the pre-edit text — every `textView.string` read is an + // O(doc) copy of the mutable backing store; this function used to take + // four of them per keystroke. + let preText = textView.string + let preNS = preText as NSString + // Open the keystroke's PERF frame HERE: the pre-edit parse and the + // smart-input interceptors below used to run before the frame existed + // and were invisible in the printed totals. + PerfTrace.begin(docLength: preNS.length) + + // Pre-edit parse for the interactive path, BEFORE the generation bump: + // the text is still pre-edit, so this O(1)-hits the cache the previous + // cycle left behind. Bumping first forced parsedDocument onto its + // O(doc) byte-compare verify on every ordinary keystroke. + let outOfBounds = affectedCharRange.location > preNS.length + || affectedCharRange.location + affectedCharRange.length > preNS.length + let isUndoRedo = textView.undoManager?.isUndoing == true + || textView.undoManager?.isRedoing == true + let interactive = !isProgrammaticEdit && !isWritingToolsActive + && !configuration.rawSourceMode && !outOfBounds && !isUndoRedo + let preEditParsed = interactive + ? PerfTrace.measure("preParse") { parsedDocument(for: preText) } + : nil + + parseGeneration &+= 1 + // Refresh the descriptor for EVERY proposed edit — including programmatic + // ones. A smart-input interceptor that suppresses a keystroke and performs + // a different edit (auto-pair, "->"→"→", Tab indent, list-exit, $$-wrap) + // would otherwise leave the suppressed edit's descriptor behind, and the + // wiki splice in textDidChange would corrupt the storage form from it. + pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) + pendingEditCount += 1 + // Pre-edit backtick window baseline for the incremental census. + if affectedCharRange.location >= 0, NSMaxRange(affectedCharRange) <= preNS.length { + pendingBacktickWindow = (affectedCharRange.location, affectedCharRange.length, + MarkdownDetection.backtickWindowCount(in: preNS, around: affectedCharRange)) + } else { + pendingBacktickWindow = nil + } if isProgrammaticEdit { return true } if isWritingToolsActive { return true } // Raw mode: plain-text editing — no smart Markdown input. if configuration.rawSourceMode { return true } - pendingEditedRange = NSRange(location: affectedCharRange.location, length: replacementString?.utf16.count ?? 0) - let currentLen = (textView.string as NSString).length - let maxR = affectedCharRange.location + affectedCharRange.length - if affectedCharRange.location > currentLen || maxR > currentLen { + if outOfBounds { pendingPreEditActiveTokenIndices = nil return false } - if textView.undoManager?.isUndoing == true || textView.undoManager?.isRedoing == true { + if isUndoRedo { pendingPreEditActiveTokenIndices = nil return true } - let parsed = parsedDocument(for: textView.string) - pendingPreEditActiveTokenIndices = MarkdownDetection.computeActiveTokenIndices( - selectionRange: textView.selectedRange(), - tokens: parsed.tokens, - in: textView.string as NSString, + guard let parsed = preEditParsed else { return true } + pendingPreEditActiveTokenIndices = activeTokenIndices( + parsed: parsed, + selection: textView.selectedRange(), + in: preNS, suppressed: !textView.isEditable ) - // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line - if MarkdownInputHandler.handleBlockLatexAutoWrap( - textView: textView, - affectedCharRange: affectedCharRange, - replacementString: replacementString, - blockLatexTokens: parsed.blockLatexTokens - ) { - return false - } + defer { PerfTrace.checkpoint("shouldOut") } + return PerfTrace.measure("smartInput") { + // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line + if MarkdownInputHandler.handleBlockLatexAutoWrap( + textView: textView, + affectedCharRange: affectedCharRange, + replacementString: replacementString, + blockLatexTokens: parsed.blockLatexTokens + ) { + return false + } - if MarkdownInputHandler.handleImageEmbedAutoWrap( - textView: textView, - affectedCharRange: affectedCharRange, - replacementString: replacementString, - imageEmbedTokens: parsed.imageEmbedTokens - ) { - return false - } + if MarkdownInputHandler.handleImageEmbedAutoWrap( + textView: textView, + affectedCharRange: affectedCharRange, + replacementString: replacementString, + imageEmbedTokens: parsed.imageEmbedTokens + ) { + return false + } - return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, replacementString: replacementString) + return MarkdownInputHandler.handleListInsertion(textView: textView, affectedCharRange: affectedCharRange, + replacementString: replacementString, codeTokens: parsed.codeTokens) + } } public func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { @@ -594,8 +785,8 @@ extension NativeTextViewCoordinator { return (containerX - f.minX) / f.width } - func updateSelectionStates(_ tv: NSTextView) { - let nsText = tv.string as NSString + func updateSelectionStates(_ tv: NSTextView, nsText: NSString? = nil) { + let nsText = nsText ?? (tv.string as NSString) let selRange = tv.selectedRange() let bus = configuration.services.bus let center = NotificationCenter.default diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 56dcf07a..fcfcdfd5 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -86,16 +86,64 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var previousActiveTokenIndices: Set = [] var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] var previousBacktickCount: Int = 0 + /// Backtick census baseline captured in shouldChangeTextIn: the pre-edit + /// window count around the proposed edit, so textDidChange can update the + /// census from the edited window alone instead of rescanning the document. + var pendingBacktickWindow: (location: Int, oldLength: Int, oldCount: Int)? + /// Set when the storage mutated without the census bookkeeping seeing it + /// (IME composition) — forces the next census back to a full scan. + var backtickCensusNeedsRescan = false + /// DEBUG-only sampling counter for verifying the incremental census. + var backtickVerifyCounter: UInt = 0 + /// Incremental parse state for this editor (buffer + blocks + tokens + /// evolve together under the edit descriptor). + let parseState = DocumentParseState() + /// Monotonic stamp for fresh ParsedDocument builds (see ParsedDocument.version). + var parsedDocumentVersion: UInt64 = 0 + /// Single-slot memo for computeActiveTokenIndices — it runs up to three + /// times per keystroke on identical inputs (pre-edit ask, selection + /// change, textDidChange). Pure function of (version, selection, suppressed). + var activeTokenMemo: (version: UInt64, selection: NSRange, suppressed: Bool, result: Set)? + + /// Display-text length after the previous textDidChange — yields the edit's + /// length delta without retaining the previous text. + var previousDisplayLength: Int = -1 + /// Storage form computed by the previous wiki sync, kept synchronously + /// (unlike `lastSyncedText`, which updates via async dispatch and can lag a + /// keystroke). This is the splice base for the incremental path. + var lastComputedStorage: String = "" + /// DEBUG-only sampling counter for verifying splices against full rebuilds. + var wikiVerifyCounter: UInt = 0 var pendingEditedRange: NSRange? = nil + /// Proposed-edit cycles since the last completed textDidChange. Exactly 1 + /// means the hoisted editedRange/lengthDelta describe a single tracked + /// edit and incremental fast paths may trust them. + var pendingEditCount = 0 +#if DEBUG + /// Diagnostic: whether the last completed textDidChange ran with a + /// trusted single-edit descriptor (fast paths). Read by tests. + var debugLastEditWasTrusted: Bool? = nil +#endif var pendingPreEditActiveTokenIndices: Set? = nil var previousCaretLocation: Int? = nil /// Drag-select suppressed a restyle; replayed on the next non-drag selection change. var needsRestyleAfterDrag = false var cachedCodeBlockTokens: [(index: Int, token: MarkdownToken)] = [] + /// Dedupe key of the last emitted code-block selections — identical + /// (parse version, scroll, width, active-code set) means identical output, + /// so the second per-keystroke invocation can skip the geometry work. + var lastCodeSelKey: (UInt64, CGFloat, CGFloat, Set)? var cachedParsedText: String? var cachedParsedDocument: ParsedDocument? + /// Monotonic edit counter: bumped whenever the text storage can have + /// changed. Lets `parsedDocument` return cache hits in O(1) instead of an + /// O(doc) string compare. Any code that mutates the storage directly + /// (bypassing shouldChangeText/textDidChange) must bump this. + var parseGeneration: UInt64 = 0 + var cachedParseGeneration: UInt64 = .max + var cachedParsedLength: Int = -1 // Skip spellcheck property setters when the state wouldn't change. var cachedSpellingDisabled: Bool? @@ -135,11 +183,28 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { struct ParsedDocument { let tokens: [MarkdownToken] + /// The block list the tokens were derived from — handed to the restyle + /// so DocumentAST.parse consumes it instead of re-deriving blocks + /// (full buffer re-extraction + memcmp per keystroke). + let blocks: [Block] let codeTokens: [MarkdownToken] let latexTokens: [MarkdownToken] let blockLatexTokens: [MarkdownToken] let wikiLinkTokens: [MarkdownToken] let imageEmbedTokens: [MarkdownToken] + let tableTokens: [MarkdownToken] + /// Code-block tokens with their index into `tokens` (active-token + /// checks need the original index) — collected in the same single + /// classification pass instead of a per-call full-token filter. + let codeBlockTokensWithIndices: [(index: Int, token: MarkdownToken)] + /// Per-kind indexed token arrays for the styler's NSImage passes, built + /// in the same single classification pass so the passes iterate small + /// scope-sliced arrays instead of walking every document token. + let classified: MarkdownStyler.ClassifiedStyleTokens + /// Bumped only when a FRESH parse builds this document — cache-hit + /// returns share the version, so (version, selection, suppressed) is + /// an exact memo key for pure derivations like active-token indices. + let version: UInt64 } enum InlineTokenContext { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift index 76ef4bf7..ea7a24f8 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift @@ -23,6 +23,12 @@ extension NativeTextView { // on every move, so setting the arrow after it flickers — skip // super entirely, like the exclusion-zone branch. NSCursor.arrow.set() + } else if isEditable, isOverWideTableOverlay(event) { + // Same treatment for wide-table scroll overlays: the overlay is a + // control surface (rendered image + horizontal scroller), not + // text, but the text view's tracking areas are not occlusion-aware + // and super keeps setting the I-beam through it. + NSCursor.arrow.set() } else { super.mouseMoved(with: event) applyReadOnlyCursor(for: event) @@ -34,12 +40,29 @@ extension NativeTextView { if isEditable { NSCursor.arrow.set() } } else if isEditable, isOverTaskCheckboxBox(event) { NSCursor.arrow.set() + } else if isEditable, isOverWideTableOverlay(event) { + NSCursor.arrow.set() } else { super.mouseEntered(with: event) applyReadOnlyCursor(for: event) } } + /// True when the pointer is over a wide-table overlay's HORIZONTAL + /// SCROLLER (mirrors the task-checkbox suppression above; read-only mode + /// already shows the arrow via `applyReadOnlyCursor`). Only the scroller + /// strip is a control surface — over the rendered table image itself the + /// normal text cursor behavior stays. + private func isOverWideTableOverlay(_ event: NSEvent) -> Bool { + guard !wideTableOverlays.isEmpty else { return false } + for (_, overlay) in wideTableOverlays where overlay.superview != nil && !overlay.isHidden { + guard let scroller = overlay.horizontalScroller, !scroller.isHidden else { continue } + let point = scroller.convert(event.locationInWindow, from: nil) + if scroller.bounds.contains(point) { return true } + } + return false + } + /// True inside an embedder exclusion zone — a panel over the editor or a /// full-window overlay (search/transfer) that owns the cursor. NOT gated on /// `isEditable`: overlays make the editor read-only, and gating let its diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 3782ef0f..9ee09f02 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -27,6 +27,7 @@ extension NativeTextView { let lineHeight = layoutBridgeDefaultLineHeight(for: self.baseFont, using: layoutBridge) // File switch/resize forces full layout until height settles; typing stays O(edit). if debugTag == "?" { pendingFullLayoutMeasure = true } + let forcedFullLayout = pendingFullLayoutMeasure let measured = measuredBaseContentHeight( minimumHeight: lineHeight, forceFullLayout: pendingFullLayoutMeasure @@ -42,6 +43,12 @@ extension NativeTextView { let overscrollChanged = abs(resolvedOverscroll - activeBottomOverscroll) > 0.5 // Height settled → stop forcing full layout (until the next switch/resize). if !(baseHeightChanged || overscrollChanged) { pendingFullLayoutMeasure = false } + // A persistent fullLayout=1 with hChanged/osChanged flipping every + // keystroke = the bistable-height loop: every keystroke then pays a + // FULL document ensureLayout inside the overscroll span. + PerfTrace.note { + "overscroll[\(debugTag)]: fullLayout=\(forcedFullLayout ? 1 : 0) h=\(Int(measured))\(baseHeightChanged ? " hChanged" : "")\(overscrollChanged ? " osChanged" : "")" + } guard baseHeightChanged || overscrollChanged else { return } baseContentHeight = measured activeBottomOverscroll = resolvedOverscroll @@ -292,6 +299,12 @@ extension NativeTextView { } override func scrollRangeToVisible(_ range: NSRange) { + // Runs from inside AppKit's post-edit processing — outside every + // sequential span; accumulate so the frame stops hiding it. + PerfTrace.accumulate("reveal") { revealRangeIfNeeded(range) } + } + + private func revealRangeIfNeeded(_ range: NSRange) { if suppressAutoRevealOnce { suppressAutoRevealOnce = false return @@ -334,26 +347,48 @@ extension NativeTextView { // Reveal the ACTUAL caret location's segment (not the stepped-back revealOffset), so a // freshly soft-wrapped last line at the document end still scrolls into view; fall back to // the stepped-back offset only when the true end-of-document has no segment of its own. - for segOffset in [min(range.location, docLength), revealOffset] { - guard let segLoc = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: segOffset) else { continue } - var found = false - tlm.enumerateTextSegments(in: NSTextRange(location: segLoc), type: .standard, options: []) { _, segFrame, _, _ in - if segFrame.height > 0 { revealRect = segFrame; found = true } // caret rect = its line, not the block - return false + func caretSegmentRect(fallback: CGRect) -> CGRect { + var rect = fallback + for segOffset in [min(range.location, docLength), revealOffset] { + guard let segLoc = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: segOffset) else { continue } + var found = false + tlm.enumerateTextSegments(in: NSTextRange(location: segLoc), type: .standard, options: []) { _, segFrame, _, _ in + if segFrame.height > 0 { rect = segFrame; found = true } // caret rect = its line, not the block + return false + } + if found { break } } - if found { break } + return rect } - let frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) + revealRect = caretSegmentRect(fallback: revealRect) + var frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) let visibleTop = cv.bounds.origin.y + insetsTop let visibleBottom = cv.bounds.origin.y + cv.bounds.height let margin: CGFloat = 24 + if frame.minY < visibleTop || frame.maxY > visibleBottom { + // The caret's Y is the sum of the fragment heights above it; + // while any of those are estimate-only (a table image estimates + // as one text line, ~250pt short), the caret reads far too high + // and typing "reveals" upward. Settle layout up to the caret + // before trusting an out-of-view verdict — spurious ones then + // dissolve, genuine jumps get the correct target. Free when the + // caret is already visible (the common per-keystroke case). + if let end = tlm.textContentManager?.location(tlm.documentRange.location, offsetBy: min(range.location + 1, docLength)), + let settleRange = NSTextRange(location: tlm.documentRange.location, end: end) { + // O(doc-start → caret) real layout — the prime suspect + // whenever `+reveal` dominates a frame. + PerfTrace.accumulate("revealSettle") { tlm.ensureLayout(for: settleRange) } + } + revealRect = caretSegmentRect(fallback: revealRect) + frame = revealRect.offsetBy(dx: 0, dy: self.frame.origin.y) + } let targetY: CGFloat if frame.minY < visibleTop { targetY = frame.minY - insetsTop - margin } else if frame.maxY > visibleBottom { targetY = frame.maxY - cv.bounds.height + margin } else { - return false // already visible + return false // already visible (or a spurious verdict, corrected) } cv.scroll(to: NSPoint(x: cv.bounds.origin.x, y: targetY)) scrollView.reflectScrolledClipView(cv) @@ -398,15 +433,17 @@ extension NativeTextView { } /// Force TextKit 2 to lay out all fragments within the current visible rect. + /// Walks from the document head, not the viewport: a fragment's Y is the + /// sum of the heights above it, so leaving anything above merely estimated + /// shifts the visible content when it later settles. A viewport-scoped walk + /// (tried as a perf win) caused content shifts, spurious caret reveals, and + /// a bistable frame height; steady-state cost here is an enumeration over + /// already-laid-out fragments. func ensureVisibleLayout() { guard let tlm = textLayoutManager else { return } - let visTop = visibleRect.minY let visBot = visibleRect.maxY tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: [.ensuresLayout]) { fragment in - let fr = fragment.layoutFragmentFrame - if fr.maxY < visTop { return true } - if fr.minY > visBot { return false } - return true + fragment.layoutFragmentFrame.minY <= visBot } } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift index 23b7b499..91d6c9a4 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+SpellingPolicy.swift @@ -13,6 +13,13 @@ import AppKit extension NativeTextView { override func setSpellingState(_ value: Int, range charRange: NSRange) { + // Spell-checker callback — fires from AppKit, potentially several + // times per keystroke, and does full-document contains() scans below; + // accumulate so the cost shows up in the PERF frame. + PerfTrace.accumulate("spellCb") { applySpellingPolicy(value, range: charRange) } + } + + private func applySpellingPolicy(_ value: Int, range charRange: NSRange) { let coordinator = delegate as? NativeTextViewCoordinator if value != 0 { if self.string.contains("`") { diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift index b7fcd800..c263d415 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView.swift @@ -89,6 +89,13 @@ final class NativeTextView: NSTextView { let coord = delegate as? NativeTextViewCoordinator else { return } let marked = markedRange() guard marked.location != NSNotFound, marked.length > 0 else { return } + // The composition mutated the storage without textDidChange, and + // shouldChangeTextIn's own parse re-cached the PRE-edit string at the + // current generation — bump so the restyle below reparses instead of + // serving that stale document (same-length composition updates). + coord.parseGeneration &+= 1 + // Census bookkeeping never saw this mutation → next census full-scans. + coord.backtickCensusNeedsRescan = true let nsText = self.string as NSString let paragraph = nsText.paragraphRange(for: marked) coord.restyleParagraphs([paragraph], in: self) diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e1a1d120..43eea010 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -464,6 +464,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { let coordinator = context.coordinator DispatchQueue.main.async { coordinator.isWikiLinkActive = false } } + // Sync the input-behavior toggles (auto-close pairs, list helpers). + // The keystroke handlers read textView.configuration live, but only + // makeNSView used to write it — an embedder settings change was inert + // until the editor was rebuilt. Plain assignment: a tiny value struct, + // and no rebuild is needed for it to take effect. + textView.configuration.lists = configuration.lists + context.coordinator.configuration.lists = configuration.lists // Reading column centers by POSITION (container subview), so the text inset is constant. let desiredTextInset = NSSize( width: configuration.textInsets.horizontal, diff --git a/Tests/MarkdownEngineTests/BacktickCensusTests.swift b/Tests/MarkdownEngineTests/BacktickCensusTests.swift new file mode 100644 index 00000000..34758b08 --- /dev/null +++ b/Tests/MarkdownEngineTests/BacktickCensusTests.swift @@ -0,0 +1,29 @@ +// +// BacktickCensusTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Backtick census") +struct BacktickCensusTests { + + @Test func matchesComponentsSemantics() { + let samples = [ + "", "`", "``", "```", "````", "`````", "``````", + "a```b```c", + "x\n```swift\nlet a = 1\n```\ny", + "inline `code` only", + "```````" + ] + for sample in samples { + let expected = sample.components(separatedBy: "```").count - 1 + #expect(MarkdownDetection.tripleBacktickCount(in: sample as NSString) == expected, + "mismatch for \(sample.debugDescription)") + } + } +} diff --git a/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift new file mode 100644 index 00000000..51656dd3 --- /dev/null +++ b/Tests/MarkdownEngineTests/FenceInteriorIncrementalTests.swift @@ -0,0 +1,74 @@ +// +// FenceInteriorIncrementalTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// Typing INSIDE a fenced code / $$ block used to bail the incremental block +// parse (full O(doc) reparse every keystroke). The window splice now handles +// interior edits; these pin the two main cases plus the indented-$$ opener +// regression the review caught. Broad differential coverage lives in the +// pre-existing ParseIncrementalEquivalenceTests fuzz (fence + $$ templates). +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Fence-interior incremental parse") +struct FenceInteriorIncrementalTests { + + private func chars(_ s: String) -> [unichar] { + let ns = s as NSString + var buf = [unichar](repeating: 0, count: ns.length) + if ns.length > 0 { ns.getCharacters(&buf, range: NSRange(location: 0, length: ns.length)) } + return buf + } + + private func splice(_ old: String, at loc: Int, remove: Int, insert: String) + -> (new: String, diff: BufferDiff) { + let ns = NSMutableString(string: old) + ns.replaceCharacters(in: NSRange(location: loc, length: remove), with: insert) + let insertLen = (insert as NSString).length + return (ns as String, BufferDiff( + changeStart: loc, + changeEndOld: loc + remove, + changeEndNew: loc + insertLen, + delta: insertLen - remove + )) + } + + /// nil = splice bailed to full parse (also correct); true/false = blocks match. + private func spliceEqualsFullParse(_ old: String, at loc: Int, remove: Int, insert: String) -> Bool? { + let (new, diff) = splice(old, at: loc, remove: remove, insert: insert) + guard let result = BlockParser.incrementalParse( + oldChars: chars(old), oldBlocks: BlockParser.computeBlocks(old), + newChars: chars(new), newNS: new as NSString, diff: diff + ) else { return nil } + return result.blocks == BlockParser.computeBlocks(new) + } + + @Test func interiorFenceEditSplicesIncrementally() { + let old = "para one\n\n```swift\nlet x = 1\nlet y = 2\n```\n\ntail paragraph" + let editLoc = (old as NSString).range(of: "x = 1").location + #expect(spliceEqualsFullParse(old, at: editLoc, remove: 1, insert: "value") == true) + } + + @Test func interiorBlockLatexEditSplicesIncrementally() { + let old = "before\n\n$$\nE = mc^2\n$$\n\nafter text" + let editLoc = (old as NSString).range(of: "mc^2").location + #expect(spliceEqualsFullParse(old, at: editLoc, remove: 0, insert: "k") == true) + } + + // Review regression: isBlockLatexOpen matches the TRIMMED line prefix, so + // an edit in the leading whitespace of an indented `$$` opener flips its + // pairing from past the old ±3-char delimiter guard. The splice must bail + // or match ground truth. + @Test func indentedLatexOpenerWhitespaceInsertStaysEquivalent() { + let old = "intro\n\n $$\n a = 1\n $$\nmiddle text\n\n$$\nb = 2\n$$\ntail" + let openerLoc = (old as NSString).range(of: " $$").location + if let result = spliceEqualsFullParse(old, at: openerLoc, remove: 0, insert: "x") { + #expect(result) + } + } +} diff --git a/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift b/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift new file mode 100644 index 00000000..654c166d --- /dev/null +++ b/Tests/MarkdownEngineTests/InterceptorStorageSyncTests.swift @@ -0,0 +1,70 @@ +// +// InterceptorStorageSyncTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Regression: smart-input interceptors suppress the typed keystroke and +// perform a different programmatic edit. The incremental wiki splice must +// see THAT edit's descriptor — with the suppressed keystroke's stale one it +// silently diverged the storage form (and the persisted file) from the +// display text. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Interceptor edits keep the storage form in sync") +struct InterceptorStorageSyncTests { + + /// Editor wired like production: coordinator as delegate, storage-sync + /// state seeded as rebuildTextStorageAndStyle would. + private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + _ = NSApplication.shared // selection-change path reads NSApp.currentEvent + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return (textView, coordinator) + } + + // "->" arrow substitution: same length delta, different location — the + // stale descriptor made the splice an identity op that kept "- " forever. + @Test func arrowSubstitutionSyncsStorage() { + let (tv, coord) = makeEditor(text: "abc - def") + tv.setSelectedRange(NSRange(location: 5, length: 0)) // right after "-" + + tv.insertText(">", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "abc → def") + #expect(coord.lastComputedStorage == "abc → def") + } + + // Tab list-indent: the edit lands at the line start, not at the caret the + // suppressed keystroke described. + @Test func tabIndentSyncsStorage() { + let (tv, coord) = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) // end of the item + + tv.insertText("\t", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "\t- hello") + #expect(coord.lastComputedStorage == "\t- hello") + } +} diff --git a/Tests/MarkdownEngineTests/InterceptorTrustTests.swift b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift new file mode 100644 index 00000000..a1f44413 --- /dev/null +++ b/Tests/MarkdownEngineTests/InterceptorTrustTests.swift @@ -0,0 +1,65 @@ +// +// InterceptorTrustTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// A smart-input interceptor suppresses the typed key and performs one +// programmatic edit; the keystroke must stay TRUSTED (single tracked edit) +// so textDidChange keeps the O(edit) fast paths instead of the O(doc) +// fallback. Storage correctness on these paths is covered by +// InterceptorStorageSyncTests; here we pin the trust flag. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Interceptor edits keep the fast paths trusted") +struct InterceptorTrustTests { + + private func makeEditor(text: String) -> (NativeTextView, NativeTextViewCoordinator) { + _ = NSApplication.shared + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return (textView, coordinator) + } + + @Test func plainTypingIsTrusted() { + let (tv, coord) = makeEditor(text: "hello") + tv.setSelectedRange(NSRange(location: 5, length: 0)) + + tv.insertText("x", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "hellox") + #expect(coord.debugLastEditWasTrusted == true) + } + + @Test func arrowSubstitutionStaysTrusted() { + let (tv, coord) = makeEditor(text: "abc - def") + tv.setSelectedRange(NSRange(location: 5, length: 0)) + + tv.insertText(">", replacementRange: NSRange(location: 5, length: 0)) + + #expect(tv.string == "abc → def") + #expect(coord.lastComputedStorage == "abc → def") + #expect(coord.debugLastEditWasTrusted == true) + } +} diff --git a/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift new file mode 100644 index 00000000..f9d4071e --- /dev/null +++ b/Tests/MarkdownEngineTests/ListHandlerCodeContextTests.swift @@ -0,0 +1,62 @@ +// +// ListHandlerCodeContextTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// The list handler now gets "is the caret in code?" from the keystroke's +// parse (codeTokens) instead of a full-document contains("`") + tokenizer +// scan on every space/Enter/Tab. These pin that list continuation still +// fires outside code and stays inert inside a fenced block. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("List handler code-block context") +struct ListHandlerCodeContextTests { + + private func makeEditor(text: String) -> NativeTextView { + _ = NSApplication.shared + let textView = NativeTextView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + textView.isEditable = true + textView.configuration = .default + let coordinator = NativeTextViewCoordinator( + text: .constant(text), + fontName: "SF Pro Text", + fontSize: 14, + isWikiLinkActive: .constant(false), + onLinkClick: nil, + onInlineSelectionChange: nil + ) + coordinator.textView = textView + textView.delegate = coordinator + textView.string = text + coordinator.lastSyncedText = text + coordinator.lastComputedStorage = text + coordinator.previousDisplayLength = (text as NSString).length + return textView + } + + @Test func enterContinuesListOutsideCode() { + let tv = makeEditor(text: "- hello") + tv.setSelectedRange(NSRange(location: 7, length: 0)) + + tv.insertText("\n", replacementRange: NSRange(location: 7, length: 0)) + + #expect(tv.string == "- hello\n- ") + } + + @Test func enterDoesNotContinueListInsideFencedCode() { + let text = "```\n- hello\n```" + let tv = makeEditor(text: text) + tv.setSelectedRange(NSRange(location: 11, length: 0)) // end of "- hello", inside the fence + + tv.insertText("\n", replacementRange: NSRange(location: 11, length: 0)) + + #expect(tv.string == "```\n- hello\n\n```") + } +} diff --git a/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift b/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift new file mode 100644 index 00000000..b1b0a350 --- /dev/null +++ b/Tests/MarkdownEngineTests/ParseIncrementalEquivalenceTests.swift @@ -0,0 +1,169 @@ +// +// ParseIncrementalEquivalenceTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 11.07.26. +// +// Differential fuzz for the incremental parse pipeline: after every random +// edit, DocumentParseState (descriptor-driven and scan-driven) must produce +// tokens identical to a from-scratch full parse, and the incremental backtick +// census must equal the full scan. The incremental paths may fall back — +// equivalence is the only contract. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Incremental parse ≡ full parse") +struct ParseIncrementalEquivalenceTests { + + // Deterministic PRNG (SplitMix64) — reproducible failures. + private struct Rng { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + mutating func int(_ upper: Int) -> Int { upper <= 0 ? 0 : Int(next() % UInt64(upper)) } + mutating func pick(_ a: [T]) -> T { a[int(a.count)] } + } + + private static let lineTemplates = [ + "Plain prose with **bold** and *italic* text here.", + "# Heading level one", + "## Second heading", + "- list item with `inline code`", + "1. ordered item", + "> a blockquote line", + "| a | b |", "|---|---|", "| 1 | 2 |", + "```swift", "let x = 1", "```", + "$$", "E = mc^2", "$$", + "A [[Wiki Link]] and ==highlight== and ~~strike~~.", + "Inline $x^2$ latex and an ![[embed.png]] image.", + "", + ] + + private static let editSnippets = [ + "x", "ab", " ", "\n", "`", "``", "```", "$", "$$", "**", "- ", "# ", + "| c |", "[[N]]", "\n\n", "word and more", + ] + + private func makeDoc(_ rng: inout Rng, lines: Int) -> String { + var out: [String] = [] + for _ in 0.. [String] { + tokens.map { + "\($0.kind)|\($0.range)|\($0.contentRange)|\($0.markerRanges.map { r in "\(r)" }.joined(separator: ","))" + } + } + + private func groundTruth(_ text: String) -> [String] { + let ns = text as NSString + return dump(MarkdownTokenizer.fullTokens(blocks: BlockParser.computeBlocks(text), ns: ns)) + } + + private func runFuzz(seed: UInt64, useDescriptor: Bool, widen: Bool = false) { + var rng = Rng(state: seed) + let state = DocumentParseState() + var text = makeDoc(&rng, lines: 40) + + // Seed the state with the initial document. + _ = state.tokens(for: text, edit: nil) + + for step in 0..<250 { + let ns = NSMutableString(string: text) + let loc = rng.int(ns.length + 1) + let removeLen = min(rng.int(6), ns.length - loc) + let insert = rng.int(4) == 0 ? "" : rng.pick(Self.editSnippets) + ns.replaceCharacters(in: NSRange(location: loc, length: removeLen), with: insert) + text = ns as String + + var edit: ParseEditDescriptor? + if useDescriptor { + var range = NSRange(location: loc, length: (insert as NSString).length) + if widen { + // A containing region must be just as correct as the minimal one. + let grow = rng.int(3) + let lo = max(0, range.location - grow) + let hi = min(ns.length, NSMaxRange(range) + grow) + range = NSRange(location: lo, length: hi - lo) + } + edit = ParseEditDescriptor(editedRange: range, delta: (insert as NSString).length - removeLen) + } + + let incremental = dump(state.tokens(for: text, edit: edit)) + let full = groundTruth(text) + #expect(incremental == full, + "seed \(seed) step \(step): incremental tokens diverged (edit at \(loc), removed \(removeLen), inserted \(insert.debugDescription))") + if incremental != full { return } // stop at first divergence, keep output readable + } + } + + @Test func descriptorDrivenMatchesFullParse() { + runFuzz(seed: 0xA11CE, useDescriptor: true) + runFuzz(seed: 0xB0B, useDescriptor: true) + } + + @Test func widenedDescriptorMatchesFullParse() { + runFuzz(seed: 0xC0FFEE, useDescriptor: true, widen: true) + } + + @Test func scanDrivenMatchesFullParse() { + runFuzz(seed: 0xD00D, useDescriptor: false) + } + + // MARK: - Backtick census + + @Test func windowCensusComposesExactly() { + // Boundary cases: completing ``` between existing backticks, joining + // fences by deleting the separator, runs of 4/6/7. + let cases: [(before: String, edit: NSRange, insert: String)] = [ + ("a``b", NSRange(location: 2, length: 0), "`"), // `` + ` → ``` + ("```\n```", NSRange(location: 3, length: 1), ""), // join to `````` + ("````x````", NSRange(location: 4, length: 1), "`"), + ("abc", NSRange(location: 1, length: 0), "```"), + ("`````", NSRange(location: 2, length: 1), ""), + ] + for c in cases { + let beforeNS = c.before as NSString + let oldWindow = MarkdownDetection.backtickWindowCount(in: beforeNS, around: c.edit) + let after = beforeNS.replacingCharacters(in: c.edit, with: c.insert) as NSString + let newRange = NSRange(location: c.edit.location, length: (c.insert as NSString).length) + let newWindow = MarkdownDetection.backtickWindowCount(in: after, around: newRange) + let composed = MarkdownDetection.tripleBacktickCount(in: beforeNS) - oldWindow + newWindow + #expect(composed == MarkdownDetection.tripleBacktickCount(in: after), + "census composition failed for \(c.before.debugDescription) + \(c.insert.debugDescription)") + } + } + + @Test func windowCensusFuzz() { + var rng = Rng(state: 0xFACE) + let alphabet = ["`", "a", "\n", "``", "b`"] + var text = (0..<60).map { _ in rng.pick(alphabet) }.joined() + for step in 0..<400 { + let ns = text as NSString + let loc = rng.int(ns.length + 1) + let removeLen = min(rng.int(4), ns.length - loc) + let insert = rng.int(4) == 0 ? "" : rng.pick(alphabet) + let editOld = NSRange(location: loc, length: removeLen) + + let oldWindow = MarkdownDetection.backtickWindowCount(in: ns, around: editOld) + let after = ns.replacingCharacters(in: editOld, with: insert) as NSString + let editNew = NSRange(location: loc, length: (insert as NSString).length) + let newWindow = MarkdownDetection.backtickWindowCount(in: after, around: editNew) + + let composed = MarkdownDetection.tripleBacktickCount(in: ns) - oldWindow + newWindow + #expect(composed == MarkdownDetection.tripleBacktickCount(in: after), "step \(step)") + if composed != MarkdownDetection.tripleBacktickCount(in: after) { return } + text = after as String + } + } +} diff --git a/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift new file mode 100644 index 00000000..1ac0c207 --- /dev/null +++ b/Tests/MarkdownEngineTests/PrecomputedBlocksTests.swift @@ -0,0 +1,40 @@ +// +// PrecomputedBlocksTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// The per-keystroke restyle hands its already-computed block list down to +// DocumentAST.parse so the styler consumes it verbatim instead of +// re-extracting + memcmp'ing the full document buffer every keystroke. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Precomputed blocks bypass the block parser") +struct PrecomputedBlocksTests { + + @Test func precomputedBlocksAreConsumedVerbatim() { + let text = "alpha\n\nbeta" + // Deliberately WRONG for this text: one paragraph covering only "alpha". + // A re-parse would see two paragraphs + a blank instead. + let bogus = [Block(kind: .paragraph, range: NSRange(location: 0, length: 6))] + + let ast = DocumentAST.parse(text, precomputedBlocks: bogus) + + #expect(ast.count == 1) + #expect(ast.first?.range == NSRange(location: 0, length: 6)) + } + + @Test func parsedDocumentCarriesTheKeystrokesBlocks() { + let state = DocumentParseState() + _ = state.tokens(for: "alpha\n\nbeta", edit: nil) + + let blocks = state.currentBlocks + + #expect(blocks.count == 3) + #expect(blocks.map(\.range).last == NSRange(location: 7, length: 4)) + } +} diff --git a/Tests/MarkdownEngineTests/TableImageCacheTests.swift b/Tests/MarkdownEngineTests/TableImageCacheTests.swift new file mode 100644 index 00000000..a2cd92d1 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableImageCacheTests.swift @@ -0,0 +1,105 @@ +// +// TableImageCacheTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table image cache") +struct TableImageCacheTests { + + private func makeContext( + for source: String, + configuration: MarkdownEditorConfiguration = .default + ) -> MarkdownStyler.StylingContext { + let font = NSFont.systemFont(ofSize: 15) + return MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: configuration, + wikiLinkIDProvider: { _ in nil } + ) + } + + @Test func secondRequestIsServedFromCache() throws { + let source = "| alpha | beta |\n|---|---|\n| 1 | 2 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 2000) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 2000) + + #expect(first.rendered) + #expect(!second.rendered) + #expect(first.image === second.image) + } + + @Test func appearanceChangeRendersFresh() throws { + let source = "| gamma | delta |\n|---|---|\n| 3 | 4 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let ctx = makeContext(for: source) + let aqua = try #require(NSAppearance(named: .aqua)) + let dark = try #require(NSAppearance(named: .darkAqua)) + + _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 2000) + let darkResult = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: dark, availableWidth: 2000) + + #expect(darkResult.rendered) + } + + // The key must cover every color renderTable draws with — mutedText paints + // the border and header fill, so a theme differing only there is a miss. + @Test func mutedTextChangeRendersFresh() throws { + let source = "| epsilon | zeta |\n|---|---|\n| 7 | 8 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var themed = MarkdownEditorConfiguration.default + themed.theme.mutedText = .systemPink + + _ = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: makeContext(for: source), appearance: aqua, availableWidth: 2000) + let repainted = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: themed), appearance: aqua, availableWidth: 2000 + ) + + #expect(repainted.rendered) + } + + // NSColor descriptions are not identities: two named dynamic colors sharing + // a name describe identically. The key must use resolved components instead. + @Test func sameNamedDynamicColorsDoNotCollide() throws { + let source = "| eta | theta |\n|---|---|\n| 9 | 10 |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let aqua = try #require(NSAppearance(named: .aqua)) + + var blueBody = MarkdownEditorConfiguration.default + blueBody.theme.bodyText = NSColor(name: "body") { _ in .systemBlue } + var redBody = MarkdownEditorConfiguration.default + redBody.theme.bodyText = NSColor(name: "body") { _ in .systemRed } + + _ = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: blueBody), appearance: aqua, availableWidth: 2000 + ) + let redRender = MarkdownStyler.tableImage( + for: source, parsed: parsed, + ctx: makeContext(for: source, configuration: redBody), appearance: aqua, availableWidth: 2000 + ) + + #expect(redRender.rendered) + } +} diff --git a/Tests/MarkdownEngineTests/TableScopeSkipTests.swift b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift new file mode 100644 index 00000000..52e98461 --- /dev/null +++ b/Tests/MarkdownEngineTests/TableScopeSkipTests.swift @@ -0,0 +1,81 @@ +// +// TableScopeSkipTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 12.07.26. +// +// styleTables used to substring + parse/hash + write .spellingState for +// EVERY table in the document per keystroke. Now a table only does that +// work when it renders (inactive + in scope) or shares a length with a +// rendering table (duplicate-sourceID stability). Typing prose renders no +// table → all skip. Table sources here are unique so they never warm the +// shared image cache used by TableImageCacheTests. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("styleTables scope skip") +struct TableScopeSkipTests { + + private func makeContext( + text: String, + scopeBounds: (lo: Int, hi: Int)? + ) -> MarkdownStyler.StylingContext { + _ = NSApplication.shared // styleTables reads NSApp.effectiveAppearance + let tokens = MarkdownTokenizer.parseTokensViaAST(in: text) + let font = NSFont.systemFont(ofSize: 15) + var ctx = MarkdownStyler.StylingContext( + nsText: text as NSString, + tokens: tokens, + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + ctx.scopeBounds = scopeBounds + return ctx + } + + private func tableRanges(in text: String) -> [NSRange] { + MarkdownTokenizer.parseTokensViaAST(in: text) + .filter { $0.kind == .table } + .map(\.range) + } + + // Typing prose far from the tables renders none → every table skips. + @Test func outOfScopeTablesEmitNothing() throws { + let table = "| kappa | lambda |\n|---|---|\n| 5 | 6 |" + let text = table + "\n\nmiddle\n\n" + table + "\n\ntrailing paragraph of prose" + let ranges = tableRanges(in: text) + #expect(ranges.count == 2) + let last = try #require(ranges.last) + let ctx = makeContext(text: text, scopeBounds: (lo: NSMaxRange(last) + 2, hi: (text as NSString).length)) + + let attrs = MarkdownStyler.styleTables(ctx) + + for range in ranges { + #expect(attrs.filter { NSIntersectionRange($0.range, range).length > 0 }.isEmpty) + } + } + + // A table inside the restyle scope must still emit its attributes. + @Test func inScopeTableStillEmitsItsAttributes() throws { + let text = "| mu | nu |\n|---|---|\n| 7 | 8 |\n\nplain paragraph text here" + let tableRange = try #require(tableRanges(in: text).first) + let ctx = makeContext(text: text, scopeBounds: (lo: 0, hi: NSMaxRange(tableRange))) + + let attrs = MarkdownStyler.styleTables(ctx) + + let touching = attrs.filter { NSIntersectionRange($0.range, tableRange).length > 0 } + #expect(!touching.isEmpty) + #expect(touching.contains { $0.attributes[.spellingState] as? Int == 0 }) + } +} diff --git a/Tests/MarkdownEngineTests/TableWrappingTests.swift b/Tests/MarkdownEngineTests/TableWrappingTests.swift new file mode 100644 index 00000000..ec7bc45e --- /dev/null +++ b/Tests/MarkdownEngineTests/TableWrappingTests.swift @@ -0,0 +1,121 @@ +// +// TableWrappingTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 13.07.26. +// +// Obsidian-style table layout: when a table's natural width exceeds the +// available container width, columns share the available width and cell +// text WRAPS onto multiple lines instead of growing the table sideways +// (the horizontal-scroll overlay remains only for tables whose per-column +// minimums genuinely don't fit). +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Table cell wrapping") +struct TableWrappingTests { + + private let wideSource = """ + | Rechtsform | Gründungskosten | Laufende Kosten | + |---|---|---| + | Einzelunternehmen (Kleingewerbe) | 20–60€ Gewerbeanmeldung, jeder Gesellschafter meldet einzeln an | ~0€, nur Steuerberater optional, dreihundert bis achthundert Euro | + | GbR | Notar und Handelsregister etwa dreihundert bis fünfhundert Euro | Gesellschaftervertrag empfohlen, Anwalt fünfhundert bis eintausendfünfhundert | + """ + + private func render(_ source: String, availableWidth: CGFloat) throws -> NSImage { + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + var ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], + codeTokens: [], + activeTokenIndices: [], + baseFont: font, + layoutBridge: nil, + baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, + latexMarkerFont: font, + configuration: .default, + wikiLinkIDProvider: { _ in nil } + ) + ctx.scopeBounds = nil + let aqua = try #require(NSAppearance(named: .aqua)) + return MarkdownStyler.tableImage( + for: source, parsed: parsed, ctx: ctx, + appearance: aqua, availableWidth: availableWidth + ).image + } + + @Test func wideTableWrapsToTheAvailableWidth() throws { + let image = try render(wideSource, availableWidth: 650) + #expect(image.size.width <= 650.5) + } + + @Test func wrappingGrowsTheRowsInstead() throws { + let narrowRender = try render(wideSource, availableWidth: 650) + let wideRender = try render(wideSource, availableWidth: 4000) + // Same content in less width must occupy more height (wrapped lines). + #expect(narrowRender.size.height > wideRender.size.height + 10) + } + + // W3C auto layout: columns never shrink below their longest unbreakable + // word — when even those minimums don't fit, the table stays WIDER than + // the container and the horizontal-scroll overlay takes over. + @Test func manyColumnsFallBackToHorizontalScroll() throws { + let source = """ + | Rechtsformvergleich | Gründungskostenaufstellung | Haftungsbeschränkung | Steuerberaterkosten | Handelsregistereintrag | Stammkapitalanforderung | + |---|---|---|---|---|---| + | Einzelunternehmen | Gewerbeanmeldung | unbeschränkt | optional | nein | keines | + """ + let image = try render(source, availableWidth: 500) + // The longest-word minimums of six columns can't fit in 500pt — the + // table must stay wider and scroll horizontally, not crush the columns. + #expect(image.size.width > 500.5) + } + + @Test func columnsNeverShrinkBelowTheLongestWord() throws { + let source = """ + | A | B | + |---|---| + | Donaudampfschifffahrtsgesellschaftskapitän | x | + """ + let image = try render(source, availableWidth: 200) + // The unbreakable word is wider than 200 — the table must exceed the + // available width rather than break mid-word. + #expect(image.size.width > 200.5) + } + + @Test func smallTableKeepsItsNaturalWidth() throws { + let source = "| a | b |\n|---|---|\n| 1 | 2 |" + let image = try render(source, availableWidth: 650) + // Far below the available width — no artificial stretching. + #expect(image.size.width < 200) + } + + @Test func differentAvailableWidthsRenderFreshImages() throws { + let source = "| wrapcache | test |\n|---|---|\n| some fairly long sentence that needs wrapping in narrow layouts | x |" + let parsed = try #require(MarkdownStyler.parseTableSource(source)) + let font = NSFont.systemFont(ofSize: 15) + let ctx = MarkdownStyler.StylingContext( + nsText: source as NSString, + tokens: [], codeTokens: [], activeTokenIndices: [], + baseFont: font, layoutBridge: nil, baseDefaultLineHeight: 18, + codeBackgroundColor: .windowBackgroundColor, latexMarkerFont: font, + configuration: .default, wikiLinkIDProvider: { _ in nil } + ) + let aqua = try #require(NSAppearance(named: .aqua)) + + let first = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + let second = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 900) + let third = MarkdownStyler.tableImage(for: source, parsed: parsed, ctx: ctx, appearance: aqua, availableWidth: 300) + + #expect(first.rendered) + #expect(second.rendered) // width is part of the cache key + #expect(!third.rendered) // same width again → cache hit + #expect(first.image.size.width != second.image.size.width) + } +} diff --git a/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift b/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift new file mode 100644 index 00000000..18cc56fa --- /dev/null +++ b/Tests/MarkdownEngineTests/WikiLinkIncrementalTests.swift @@ -0,0 +1,144 @@ +// +// WikiLinkIncrementalTests.swift +// MarkdownEngine +// +// Created by Luca Chen on 07.07.26. +// + +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("WikiLink incremental storage state") +struct WikiLinkIncrementalTests { + + /// storage: "Hello [[Note|abc123]] world" → display: "Hello [[Note]] world" + /// display link range {6, 8}, storage link range {6, 15}. + private func seed() -> (display: String, storage: String, + meta: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata]) { + let storage = "Hello [[Note|abc123]] world" + let state = WikiLinkService.makeDisplayState(from: storage) + return (state.display, storage, state.metadata) + } + + @Test func appendAfterLinkSplicesStorage() throws { + let (display, storage, meta) = seed() + let newDisplay = display + "x" // typed "x" at the end + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: (display as NSString).length, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] worldx") + // Link before the edit: metadata unchanged. + let key = try #require(result.metadata.keys.first) + #expect(key.location == 6 && key.length == 8) + #expect(result.metadata[key]?.id == "abc123") + #expect(result.metadata[key]?.storageRange == NSRange(location: 6, length: 15)) + } + + @Test func insertBeforeLinkShiftsMetadata() throws { + let (display, storage, meta) = seed() + let newDisplay = "x" + display // typed "x" at position 0 + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 0, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "xHello [[Note|abc123]] world") + let key = try #require(result.metadata.keys.first) + #expect(key.location == 7 && key.length == 8) // shifted by +1 + #expect(result.metadata[key]?.storageRange == NSRange(location: 7, length: 15)) + #expect(result.metadata[key]?.id == "abc123") + } + + @Test func deletionInPlainTextWorks() throws { + let (display, storage, meta) = seed() + // Delete the "l" of "world" (display location 18 — far enough from the + // link that neither the ±3 probe nor the metadata guard band trips). + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 18, length: 1), with: "") + let result = try #require(WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 18, length: 0), + changeInLength: -1, + previousStorage: storage, + previousMetadata: meta + )) + #expect(result.storage == "Hello [[Note|abc123]] word") + } + + @Test func editTouchingLinkFallsBack() { + let (display, storage, meta) = seed() + // Typing directly after "]]" (display location 14) is inside the ±3 guard band. + let result = WikiLinkService.updatedStorageState( + displayText: display + " ", + editedRange: NSRange(location: 14, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + @Test func editCreatingLinkSyntaxFallsBack() { + // "[[x]" + typed "]" completes a link → the new-text probe sees "]]" → bail. + let display = "pre [[x] post" + let ns = display as NSString + let newDisplay = ns.replacingCharacters(in: NSRange(location: 8, length: 0), with: "]") + let result = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: 8, length: 1), + changeInLength: 1, + previousStorage: display, + previousMetadata: [:] + ) + #expect(result == nil) + } + + @Test func unknownDeltaFallsBack() { + let (display, storage, meta) = seed() + let result = WikiLinkService.updatedStorageState( + displayText: display, + editedRange: NSRange(location: 0, length: 0), + changeInLength: Int.min, + previousStorage: storage, + previousMetadata: meta + ) + #expect(result == nil) + } + + /// Property sweep: inserting one char at every position that takes the fast + /// path must yield a storage string that (a) round-trips back to exactly the + /// new display text and (b) preserves every link id. (Comparing against a + /// full `makeStorageState(textStorage: nil)` rebuild would be wrong here — + /// the full rebuild loses ids when link ranges shift, which is precisely + /// what the incremental path fixes.) + @Test func spliceRoundTripsAtEverySafePosition() { + let storage = "aaaa [[One|id1]] bbbb [[Two|id2]] cccc" + let state = WikiLinkService.makeDisplayState(from: storage) + let display = state.display as NSString + var fastPathTaken = 0 + + for position in 0...display.length { + let newDisplay = display.replacingCharacters(in: NSRange(location: position, length: 0), with: "q") + guard let fast = WikiLinkService.updatedStorageState( + displayText: newDisplay, + editedRange: NSRange(location: position, length: 1), + changeInLength: 1, + previousStorage: storage, + previousMetadata: state.metadata + ) else { continue } // guarded position → fallback, fine + fastPathTaken += 1 + let roundTrip = WikiLinkService.makeDisplayState(from: fast.storage) + #expect(roundTrip.display == newDisplay, "display round-trip diverged at position \(position)") + #expect(fast.metadata.values.compactMap(\.id).sorted() == ["id1", "id2"], + "link id lost at position \(position)") + } + #expect(fastPathTaken > 0, "sweep was vacuous — no position took the fast path") + } +}