diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b8c7074 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-macos: + name: macOS (swift test) + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # swift-tools-version 6.2 requires Xcode 26; pick the newest installed. + - name: Select latest Xcode 26 + run: | + LATEST=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true) + if [ -n "$LATEST" ]; then + sudo xcode-select -s "$LATEST/Contents/Developer" + fi + xcodebuild -version + swift --version + + - name: Cache SwiftPM build + uses: actions/cache@v4 + with: + path: .build + key: spm-macos-${{ hashFiles('Package.resolved') }} + restore-keys: spm-macos- + + # Benchmarks (PicoMarkdownViewBenchmarks) are excluded: they measure + # throughput and are meant for local before/after comparisons, not a + # pass/fail gate on shared runners with noisy neighbors. + - name: Run tests + run: swift test --filter PicoMarkdownViewTests + + test-ios: + name: iOS Simulator (xcodebuild test) + runs-on: macos-26 + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + + - name: Select latest Xcode 26 + run: | + LATEST=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true) + if [ -n "$LATEST" ]; then + sudo xcode-select -s "$LATEST/Contents/Developer" + fi + xcodebuild -version + + # Runner images add/remove device types over time, so resolve an + # available iPhone at runtime instead of hardcoding a name. + - name: Pick an iPhone simulator + id: sim + run: | + DEVICE=$(xcrun simctl list devices available | grep -m1 'iPhone' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*\(.*$//') + if [ -z "$DEVICE" ]; then + echo "No available iPhone simulator found" >&2 + xcrun simctl list devices >&2 + exit 1 + fi + echo "Using simulator: $DEVICE" + echo "device=$DEVICE" >> "$GITHUB_OUTPUT" + + - name: Run tests + run: | + set -o pipefail + xcodebuild test \ + -scheme PicoMarkdownView \ + -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.device }}" \ + -only-testing:PicoMarkdownViewTests \ + -resultBundlePath TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO + + - name: Upload result bundle on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-test-results + path: TestResults.xcresult + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index a913979..9aee394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ public enum BlockEvent: Sendable { case blockAppendFencedCode(id: BlockID, textChunk: String) // verbatim, no inline parsing // Table-specific deltas (GFM) - case tableHeaderCandidate(id: BlockID, cells: [InlineRun]) // before separator + case tableHeaderCandidate(id: BlockID, cells: [[InlineRun]]) // inline-parsed header cells case tableHeaderConfirmed(id: BlockID, alignments: [TableAlignment]) // after separator case tableAppendRow(id: BlockID, cells: [[InlineRun]]) @@ -159,8 +159,8 @@ Parser architecture • FSM + bounded look-behind (≈256–1024 code units) to resolve ambiguous constructs (* vs literal, closing ```). • Streaming guarantees: emit events only when constructs are unambiguous within the window. • Tables: - • Header line → tableHeaderCandidate. - • Separator confirms → tableHeaderConfirmed(alignments:). + • Header line is buffered locally; nothing is emitted until the separator line resolves the candidate (events drain to the assembler at every chunk boundary, so announcing earlier would require retracting events when the candidate degrades). + • Separator confirms → blockStart(.table) + tableHeaderCandidate(cells:) + tableHeaderConfirmed(alignments:) emitted together. Header cells are inline-parsed like row cells. • Rows as tableAppendRow. • Fallback: unsupported or malformed block → .unknown via blockStart(kind:.unknown) + blockAppendInline. @@ -349,15 +349,16 @@ Input 3. feed("| a1 | b1 |\n| a2 | b2 |\n\n") Expected - 1. → BS(.table), THC(cells:[ "Col A", "Col B" ]) - 2. → THE(align:[ .left, .center ]) - 3. → TAR(cells:[[ "a1","b1" ]]), -TAR(cells:[[ "a2","b2" ]]), + 1. → (no events; the header line is buffered locally as a candidate) + 2. → BS(.table), THC(cells:[[ "Col A" ],[ "Col B" ]]), THE(align:[ .left, .center ]) + 3. → TAR(cells:[[ "a1" ],[ "b1" ]]), +TAR(cells:[[ "a2" ],[ "b2" ]]), BE Notes - • Before the separator line arrives, it’s a header candidate only. - • After confirmation, rows append as cells of InlineRuns with plain styles. + • Nothing is emitted for the candidate until the separator confirms it; events drain to the assembler at every chunk boundary, so an earlier announcement could not be retracted when the candidate degrades to .unknown. + • Header cells are inline-parsed like row cells: THC carries [[InlineRun]] (one run array per cell), so | **bold** | headers render styled. + • While unconfirmed, the candidate is also excluded from openBlocks. ⸻ diff --git a/README.md b/README.md index 3babfab..8aadf51 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PicoMarkdownView +[![CI](https://github.com/PicoMLX/PicoMarkdownView/actions/workflows/ci.yml/badge.svg)](https://github.com/PicoMLX/PicoMarkdownView/actions/workflows/ci.yml) + SwiftUI component for rendering streaming Markdown and KaTeX in chat-style apps on iOS 18+ and macOS 15+. ## Installation @@ -24,11 +26,10 @@ var body: some View { } ``` -For streaming, pass chunks or an async stream: +For live streaming, pass an async stream — chunks are fed to the parser +incrementally as they arrive: ```swift -PicoMarkdownView(chunks: ["Hello ", "world", "\n\n"]) - PicoMarkdownView(stream: { AsyncStream { continuation in continuation.yield("Hello ") @@ -38,6 +39,15 @@ PicoMarkdownView(stream: { }) ``` +To replay an already collected sequence of chunks (e.g. a finished +response) in one shot, pass the array directly. Note that each delivery is +parsed as a complete document, so this is not intended for feeding a +growing array during live streaming — use `stream:` for that: + +```swift +PicoMarkdownView(chunks: ["Hello ", "world", "\n\n"]) +``` + The view maintains continuous selection and reuses layout via a shared `NSTextStorage` / TextKit host under the hood. ### Configuration diff --git a/Sources/PicoMarkdownView/Assembler/MarkdownAssembler.swift b/Sources/PicoMarkdownView/Assembler/MarkdownAssembler.swift index db3a4c8..25bf474 100644 --- a/Sources/PicoMarkdownView/Assembler/MarkdownAssembler.swift +++ b/Sources/PicoMarkdownView/Assembler/MarkdownAssembler.swift @@ -326,10 +326,10 @@ private struct BlockEntry { return bytes } - mutating func setTableHeaderCandidate(_ cells: [InlineRun], allowCoalescing: Bool) -> Int { + mutating func setTableHeaderCandidate(_ cells: [[InlineRun]], allowCoalescing: Bool) -> Int { ensureTableState() let normalized = cells.map { cell -> [InlineRun] in - allowCoalescing ? BlockEntry.coalescedRuns([cell]) : [cell] + allowCoalescing ? BlockEntry.coalescedRuns(cell) : cell } let newBytes = BlockEntry.byteCount(forCells: normalized) let delta = newBytes - (table?.headerByteCount ?? 0) diff --git a/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift b/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift index b10c327..475c8e0 100644 --- a/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift +++ b/Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift @@ -81,20 +81,31 @@ actor MarkdownAttributeBuilder { let codeBlockSpacing = collapsedSpacing(for: snapshot.kind, previousKind: previousBlockKind) let content: NSMutableAttributedString if let codeTheme = theme.codeBlockTheme { - let highlighter = theme.codeHighlighter ?? AnyCodeSyntaxHighlighter(PlainCodeSyntaxHighlighter()) - let highlighted = await highlighter.highlight(text, language: { - if case let .fencedCode(value) = snapshot.kind { - return value - } - return nil - }(), theme: codeTheme) - content = NSMutableAttributedString(highlighted) - let resolvedCodeFont = codeTheme.resolvedFont() let resolvedFg = codeTheme.resolvedForegroundColor() let resolvedBg = codeTheme.resolvedBackgroundColor() let hasBackground = codeTheme.backgroundColor != .clear + if snapshot.isClosed { + let highlighter = theme.codeHighlighter ?? AnyCodeSyntaxHighlighter(PlainCodeSyntaxHighlighter()) + let highlighted = await highlighter.highlight(text, language: { + if case let .fencedCode(value) = snapshot.kind { + return value + } + return nil + }(), theme: codeTheme) + content = NSMutableAttributedString(highlighted) + } else { + // While the fence is open, every appended chunk re-renders + // the whole block, so running the syntax highlighter here + // would be O(block²) over the stream. Render with the + // theme's base attributes and highlight once, on close. + content = NSMutableAttributedString(string: text, attributes: [ + .font: resolvedCodeFont, + .foregroundColor: resolvedFg + ]) + } + if content.length > 0 { applyCodeBlockParagraphStyles(to: content, spacing: codeBlockSpacing) if hasBackground { @@ -249,6 +260,21 @@ actor MarkdownAttributeBuilder { private func renderHorizontalRule() -> NSAttributedString { _ = paragraphSpacing() + #if !canImport(AppKit) + // UIKit has no NSTextTable, so a border-drawn hairline is not + // available. Approximate the rule with connecting box-drawing glyphs + // in the secondary label color. + let ruleParagraph = NSMutableParagraphStyle() + ruleParagraph.alignment = .left + ruleParagraph.lineBreakMode = .byClipping + ruleParagraph.paragraphSpacing = 20 + ruleParagraph.paragraphSpacingBefore = 20 + return NSAttributedString(string: String(repeating: "\u{2500}", count: 32) + "\n", attributes: [ + .paragraphStyle: ruleParagraph, + .font: bodyFont, + .foregroundColor: PlatformColor.rendererSecondaryLabel + ]) + #else // Use a 1-column NSTextTable that spans 100% width with a top border to emulate an HR let table = NSTextTable() table.numberOfColumns = 1 @@ -293,6 +319,7 @@ actor MarkdownAttributeBuilder { // ])) // No extra blank paragraph appended here return result + #endif } private func renderInlineBlock(_ snapshot: BlockSnapshot, @@ -733,6 +760,112 @@ actor MarkdownAttributeBuilder { return paragraphStyle } +#if !canImport(AppKit) + /// UIKit fallback: iOS TextKit has no `NSTextTable`, so tables render as + /// styled text rows — bold header, cells joined by a thin vertical + /// separator — until a native iOS table presentation exists. + /// `RenderedTable` is still populated with per-cell content so the view + /// layer (or a future overlay) has the structured data. + private func renderTable(_ snapshot: BlockSnapshot, font: PlatformFont) async -> (NSAttributedString, RenderedTable?, [RenderedImage]) { + guard let table = snapshot.table else { return (NSAttributedString(), nil, []) } + + let maxRowColumns = table.rows.reduce(0) { max($0, $1.count) } + let columnCount = max(table.headerCells?.count ?? 0, maxRowColumns) + guard columnCount > 0 else { return (NSAttributedString(), nil, []) } + + var renderedTable = RenderedTable(headers: nil, rows: [], alignments: table.alignments) + var collectedImages: [RenderedImage] = [] + var imageIndex = 0 + let result = NSMutableAttributedString() + + if let headers = table.headerCells, !headers.isEmpty { + let (headerAttributed, headerCells) = await renderTableRow(cells: headers, + numberOfColumns: columnCount, + font: font, + blockID: snapshot.id, + imageCounter: &imageIndex, + collectedImages: &collectedImages, + isHeader: true) + renderedTable.headers = headerCells + result.append(headerAttributed) + } + + var renderedRows: [[AttributedString]] = [] + for row in table.rows { + let (rowAttributed, renderedCells) = await renderTableRow(cells: row, + numberOfColumns: columnCount, + font: font, + blockID: snapshot.id, + imageCounter: &imageIndex, + collectedImages: &collectedImages, + isHeader: false) + renderedRows.append(renderedCells) + result.append(rowAttributed) + } + + renderedTable.rows = renderedRows + result.append(NSAttributedString(string: "\n", attributes: [.font: font])) + return (result, renderedTable, collectedImages) + } + + private func renderTableRow(cells: [[InlineRun]], + numberOfColumns: Int, + font: PlatformFont, + blockID: BlockID, + imageCounter: inout Int, + collectedImages: inout [RenderedImage], + isHeader: Bool) async -> (NSAttributedString, [AttributedString]) { + let rowAttributed = NSMutableAttributedString() + var renderedCells: [AttributedString] = [] + let displayFont = isHeader ? boldFont(from: font) : font + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + paragraph.lineBreakMode = .byWordWrapping + paragraph.paragraphSpacing = 2 + + let cellSeparator = NSAttributedString(string: " \u{2502} ", attributes: [ + .font: font, + .foregroundColor: PlatformColor.rendererSecondaryLabel + ]) + + for column in 0.. 0 ? NSMutableAttributedString(attributedString: inline) : NSMutableAttributedString(string: " ") + let cellRange = NSRange(location: 0, length: cellContent.length) + // Fill the base font and label color only where inline runs + // didn't set one, so run-level styling (bold, code, links) + // survives — mirroring the AppKit cell path. + cellContent.enumerateAttribute(.font, in: cellRange, options: []) { value, range, _ in + if value == nil { + cellContent.addAttribute(.font, value: displayFont, range: range) + } + } + cellContent.enumerateAttribute(.foregroundColor, in: cellRange, options: []) { value, range, _ in + if value == nil { + cellContent.addAttribute(.foregroundColor, value: PlatformColor.rendererLabel, range: range) + } + } + + renderedCells.append(AttributedString(cellContent)) + if column > 0 { + rowAttributed.append(cellSeparator) + } + rowAttributed.append(cellContent) + } + + rowAttributed.append(NSAttributedString(string: "\n", attributes: [.font: displayFont])) + rowAttributed.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: rowAttributed.length)) + + return (rowAttributed, renderedCells) + } +#else private func renderTable(_ snapshot: BlockSnapshot, font: PlatformFont) async -> (NSAttributedString, RenderedTable?, [RenderedImage]) { guard let table = snapshot.table else { return (NSAttributedString(), nil, []) } @@ -830,11 +963,22 @@ actor MarkdownAttributeBuilder { } let cellContent = inline.length > 0 ? NSMutableAttributedString(attributedString: inline) : NSMutableAttributedString(string: " ") - cellContent.addAttributes([ - .paragraphStyle: paragraph, - .font: displayFont, - .foregroundColor: PlatformColor.rendererLabel - ], range: NSRange(location: 0, length: cellContent.length)) + let cellRange = NSRange(location: 0, length: cellContent.length) + // The table block/alignment must cover the whole cell, but the + // run-level fonts and colors produced by inline styling (bold, + // italic, code, links) must survive — only fill the base font and + // label color where a run didn't set one. + cellContent.addAttribute(.paragraphStyle, value: paragraph, range: cellRange) + cellContent.enumerateAttribute(.font, in: cellRange, options: []) { value, range, _ in + if value == nil { + cellContent.addAttribute(.font, value: displayFont, range: range) + } + } + cellContent.enumerateAttribute(.foregroundColor, in: cellRange, options: []) { value, range, _ in + if value == nil { + cellContent.addAttribute(.foregroundColor, value: PlatformColor.rendererLabel, range: range) + } + } renderedCells.append(AttributedString(cellContent)) rowAttributed.append(cellContent) @@ -846,6 +990,7 @@ actor MarkdownAttributeBuilder { return (rowAttributed, renderedCells) } +#endif private func tableTextAlignment(for column: Int, alignments: [TableAlignment]?) -> NSTextAlignment { guard let alignments, column < alignments.count else { return .left } diff --git a/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift b/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift index e3fd495..c7ae960 100644 --- a/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift +++ b/Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift @@ -82,8 +82,6 @@ actor MarkdownRenderer { private let snapshotProvider: SnapshotProvider private var blocks: [RenderedBlock] = [] private var indexByID: [BlockID: Int] = [:] - private var cachedAttributedString = AttributedString() - private var blockCharacterOffsets: [Int] = [] private var mermaidContentWidthBucket: Int? init(theme: MarkdownRenderTheme = .default(), @@ -98,9 +96,19 @@ actor MarkdownRenderer { self.snapshotProvider = snapshotProvider } + /// Applies a diff to the per-block render cache. Returns whether anything + /// visible changed. + /// + /// The renderer deliberately does **not** maintain a spliced full-document + /// `AttributedString` here: keeping one current costs O(document) per + /// chunk (character-offset walks plus a splice), while the streaming view + /// layer only ever consumes `renderedBlocks()` and applies its own + /// per-block edits to `NSTextStorage`. Callers that need the joined + /// document (debug store, tests) get it on demand from + /// `currentAttributedString()`. @discardableResult - func apply(_ diff: AssemblerDiff) async -> AttributedString? { - guard !diff.changes.isEmpty else { return nil } + func apply(_ diff: AssemblerDiff) async -> Bool { + guard !diff.changes.isEmpty else { return false } var mutated = false @@ -121,11 +129,15 @@ actor MarkdownRenderer { } } - return mutated ? makeSnapshot() : nil + return mutated } func currentAttributedString() -> AttributedString { - makeSnapshot() + var joined = AttributedString() + for block in blocks { + joined.append(block.content) + } + return joined } func renderedBlocks() -> [RenderedBlock] { @@ -171,23 +183,15 @@ actor MarkdownRenderer { return mutated ? blocks : nil } - private func makeSnapshot() -> AttributedString { - cachedAttributedString - } - private func insertBlock(id: BlockID, at position: Int) async { guard indexByID[id] == nil else { return } let snapshot = await snapshotProvider(id) let previousKind = previousBlockKind(at: position) let block = await buildRenderedBlock(id: id, snapshot: snapshot, previousBlockKind: previousKind) let index = max(0, min(position, blocks.count)) - - let insertionPoint = rangeStartForBlock(at: index) - cachedAttributedString.replaceSubrange(insertionPoint.. Bool { @@ -198,19 +202,9 @@ actor MarkdownRenderer { let oldContent = blocks[index].content let newContent = rendered.attributed - - var didMutate = false - if oldContent != newContent { - let range = rangeForBlock(at: index) - cachedAttributedString.replaceSubrange(range, with: newContent) - - blocks[index].content = rendered.attributed - if oldContent.characters.count != newContent.characters.count { - rebuildCharacterOffsets(startingAt: index + 1) - } - didMutate = true - } - + + let didMutate = oldContent != newContent + blocks[index].kind = snapshot.kind blocks[index].snapshot = snapshot blocks[index].content = rendered.attributed @@ -230,22 +224,13 @@ actor MarkdownRenderer { let upper = min(range.upperBound, blocks.count) guard lower < upper else { return } let removalRange = lower.. 0 { - blockCharacterOffsets.removeLast(removeCount) - } - } - - var cumulative: Int - if clampedStart > 0 { - if blockCharacterOffsets.count >= clampedStart { - let previousOffset = blockCharacterOffsets[clampedStart - 1] - cumulative = previousOffset + blocks[clampedStart - 1].content.characters.count - } else { - cumulative = blocks[.. AttributedString.Index { - guard !blocks.isEmpty else { return cachedAttributedString.startIndex } - - if index <= 0 { - return cachedAttributedString.startIndex - } - - if index >= blockCharacterOffsets.count { - return cachedAttributedString.endIndex - } - - let offset = blockCharacterOffsets[index] - return cachedAttributedString.index(cachedAttributedString.startIndex, offsetByCharacters: offset) - } - - private func rangeForBlock(at index: Int) -> Range { - let start = rangeStartForBlock(at: index) - let content = blocks[index].content - let distance = content.characters.count - let end = cachedAttributedString.index(start, offsetByCharacters: distance) - return start.. MarkdownImageResult? } +/// Default remote image loader. +/// +/// Markdown rendered by this package is typically untrusted (LLM output), so +/// the provider enforces hard limits: downloads are capped at +/// ``maxDownloadByteCount`` (checked against `Content-Length` before the body +/// is read, and streamed with an incremental cap when the header is absent), +/// requests time out, and decoded images are kept in a count-bounded LRU +/// cache instead of growing without limit. public actor URLSessionMarkdownImageProvider: MarkdownImagePrefetchingProvider { public static let shared = URLSessionMarkdownImageProvider() + /// Maximum accepted response body for a single image (8 MB). + public static let maxDownloadByteCount = 8 * 1024 * 1024 + /// Maximum number of decoded images retained in memory. + public static let maxCachedImages = 96 + private let session: URLSession + /// A shared download with a claim count. Concurrent prefetches of the + /// same URL await one task; a cancelled awaiter releases its claim and + /// the download is cancelled only when no claims remain, so one view + /// scrolling away cannot kill a download another view is waiting on. + /// `id` guards cleanup against racing a *newer* entry for the same URL. + private struct InFlightDownload { + let id: UInt64 + let task: Task + var waiters: Int + } + private var cache: [URL: MarkdownImageResult] = [:] - private var inFlight: [URL: Task] = [:] + private var lru: [URL] = [] + private var inFlight: [URL: InFlightDownload] = [:] + private var nextDownloadID: UInt64 = 0 - public init(session: URLSession = .shared) { - self.session = session + public init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 60 + configuration.waitsForConnectivity = false + self.session = URLSession(configuration: configuration) + } } public func image(for url: URL) async -> MarkdownImageResult? { guard Self.isSupportedRemoteURL(url) else { return nil } - return cache[url] + guard let cached = cache[url] else { return nil } + touch(url) + return cached } func prefetch(_ url: URL) async -> MarkdownImageResult? { guard Self.isSupportedRemoteURL(url) else { return nil } if let cached = cache[url] { + touch(url) return cached } - if let existing = inFlight[url] { - return await existing.value - } - let task = Task { [session] in - do { - let (data, response) = try await session.data(from: url) - if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + let entryID: UInt64 + let downloadTask: Task + if var existing = inFlight[url] { + existing.waiters += 1 + inFlight[url] = existing + entryID = existing.id + downloadTask = existing.task + } else { + nextDownloadID &+= 1 + let id = nextDownloadID + let task = Task { [session] in + do { + let data = try await Self.download(url: url, session: session) + return data.flatMap(Self.decodeImage(data:)) + } catch { return nil } - return Self.decodeImage(data: data) - } catch { - return nil } + inFlight[url] = InFlightDownload(id: id, task: task, waiters: 1) + entryID = id + downloadTask = task } - inFlight[url] = task - let result = await task.value - inFlight[url] = nil + // Awaiting an unstructured Task's value does not forward the caller's + // cancellation into it; release this caller's claim explicitly so an + // abandoned prefetch stops the network transfer once no other view is + // waiting on the same URL. + let result = await withTaskCancellationHandler { + await downloadTask.value + } onCancel: { + Task { await self.releaseWaiter(url: url, entryID: entryID) } + } + + if let entry = inFlight[url], entry.id == entryID { + inFlight[url] = nil + } if let result { - cache[url] = result + insert(result, for: url) } return result } + private func releaseWaiter(url: URL, entryID: UInt64) { + guard var entry = inFlight[url], entry.id == entryID else { return } + entry.waiters -= 1 + if entry.waiters <= 0 { + entry.task.cancel() + inFlight[url] = nil + } else { + inFlight[url] = entry + } + } + + private func insert(_ result: MarkdownImageResult, for url: URL) { + cache[url] = result + touch(url) + while lru.count > Self.maxCachedImages { + let victim = lru.removeFirst() + cache[victim] = nil + } + } + + private func touch(_ url: URL) { + if let index = lru.firstIndex(of: url) { + lru.remove(at: index) + } + lru.append(url) + } + + /// Downloads the response body, rejecting it as soon as either the + /// declared `Content-Length` or the accumulated byte count exceeds the + /// cap. Uses a `URLSessionDataDelegate` so the body arrives in + /// transport-sized `Data` chunks — enforcing the cap incrementally + /// without the per-byte async overhead of `URLSession.AsyncBytes`. + /// + /// Cancelling the surrounding Swift task (view reset, scroll-out) cancels + /// the underlying data task too, so abandoned prefetches stop downloading + /// instead of running to timeout or the byte cap. + private static func download(url: URL, session: URLSession) async throws -> Data? { + let task = session.dataTask(with: url) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let delegate = CappedDownloadDelegate(byteLimit: maxDownloadByteCount) { result in + continuation.resume(with: result) + } + task.delegate = delegate + task.resume() + } + } onCancel: { + // Triggers didCompleteWithError(NSURLErrorCancelled) on the + // delegate, which resumes the continuation exactly once. + task.cancel() + } + } + + /// Accumulates a capped response body. URLSession serializes all delegate + /// callbacks on its delegate queue, so the mutable state needs no locking + /// (`@unchecked Sendable` relies on that serialization). + private final class CappedDownloadDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let byteLimit: Int + private var buffer = Data() + private var completion: ((Result) -> Void)? + + init(byteLimit: Int, completion: @escaping (Result) -> Void) { + self.byteLimit = byteLimit + self.completion = completion + } + + func urlSession(_ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + finish(.success(nil)) + completionHandler(.cancel) + return + } + let expected = response.expectedContentLength + if expected != NSURLSessionTransferSizeUnknown, expected > Int64(byteLimit) { + finish(.success(nil)) + completionHandler(.cancel) + return + } + if expected > 0 { + buffer.reserveCapacity(Int(min(expected, Int64(byteLimit)))) + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard completion != nil else { return } + // Reject before appending so a server that omits or understates + // Content-Length cannot force even a transient allocation beyond + // the cap. + guard buffer.count + data.count <= byteLimit else { + finish(.success(nil)) + dataTask.cancel() + return + } + buffer.append(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error { + finish(.failure(error)) + } else { + finish(.success(buffer)) + } + } + + /// Resumes the continuation exactly once. Cancelling a task after an + /// early rejection still triggers `didCompleteWithError`, which must + /// not resume again. + private func finish(_ result: Result) { + guard let completion else { return } + self.completion = nil + completion(result) + } + } + private static func isSupportedRemoteURL(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } return scheme == "http" || scheme == "https" diff --git a/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift b/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift index 26c6e14..d5f342c 100644 --- a/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift +++ b/Sources/PicoMarkdownView/Tokenizer/BlockStateMachine.swift @@ -929,6 +929,14 @@ struct StreamingParser { table.bufferedLines.append(rawLine) if let alignments = parseAlignment(trimmed) { table.alignments = alignments + // The candidate is now a confirmed table: announce the block + // and the header in one batch. Header cells get the same + // inline parsing as row cells so `| **bold** |` renders + // identically in headers and body rows. + let headerLine = table.bufferedLines.first ?? "" + let headerCells = splitCells(headerLine).map { InlineParser.parseAll($0, tagPrefixes: tagPrefixes) } + events.append(.blockStart(id: ctx.id, kind: .table)) + events.append(.tableHeaderCandidate(id: ctx.id, cells: headerCells)) events.append(.tableHeaderConfirmed(id: ctx.id, alignments: alignments)) table.stage = .rows table.bufferedLines.removeAll(keepingCapacity: true) @@ -955,12 +963,13 @@ struct StreamingParser { } } + /// Replays a failed table candidate's buffered lines as an unknown block. + /// + /// Degradation only happens from the `.header`/`.separatorPending` stages, + /// and `openTable` emits nothing for those stages, so there are no + /// already-delivered table events to retract here — the block ID is fresh + /// from the assembler's point of view and can be reused for the fallback. private mutating func degradeTableCandidate(context ctx: inout BlockContext, table: TableState, terminated: Bool) { - let startIndex = min(ctx.eventStartIndex, events.count) - if startIndex < events.count { - events.removeSubrange(startIndex.. 0 ? contextStack[index - 1].id : nil states.append(OpenBlockState(id: context.id, kind: context.kind, parentID: parentID, depth: index)) } diff --git a/Sources/PicoMarkdownView/Tokenizer/MarkdownTokenizer.swift b/Sources/PicoMarkdownView/Tokenizer/MarkdownTokenizer.swift index b5d1a8b..ad0dc24 100644 --- a/Sources/PicoMarkdownView/Tokenizer/MarkdownTokenizer.swift +++ b/Sources/PicoMarkdownView/Tokenizer/MarkdownTokenizer.swift @@ -285,7 +285,7 @@ public enum BlockEvent: Sendable, Equatable { case blockAppendInline(id: BlockID, runs: [InlineRun]) case blockAppendFencedCode(id: BlockID, textChunk: String) case blockAppendMath(id: BlockID, textChunk: String) - case tableHeaderCandidate(id: BlockID, cells: [InlineRun]) + case tableHeaderCandidate(id: BlockID, cells: [[InlineRun]]) case tableHeaderConfirmed(id: BlockID, alignments: [TableAlignment]) case tableAppendRow(id: BlockID, cells: [[InlineRun]]) case blockEnd(id: BlockID) diff --git a/Sources/PicoMarkdownView/Views/MarkdownStreamingInput.swift b/Sources/PicoMarkdownView/Views/MarkdownStreamingInput.swift index f45b87a..727c06c 100644 --- a/Sources/PicoMarkdownView/Views/MarkdownStreamingInput.swift +++ b/Sources/PicoMarkdownView/Views/MarkdownStreamingInput.swift @@ -7,25 +7,80 @@ public struct MarkdownStreamingInput: Sendable { case stream } - let id: UUID + /// Identity used by `PicoMarkdownView`'s `.task(id:)` to decide when the + /// consume task must restart. + /// + /// SwiftUI reconstructs view values (and therefore this input) on every + /// parent body evaluation, so the id must be **deterministic for equal + /// content**: `.text`/`.chunks` derive it from the payload, which makes a + /// re-render with unchanged content a no-op instead of a full reparse (or, + /// worse, a duplicate feed). A `.stream` factory closure has no comparable + /// content, so it gets a unique id; `PicoMarkdownView` substitutes a + /// per-view-identity token as the task id for stream inputs so re-renders + /// don't restart consumption. + /// + /// The hash is `Hasher`-based and therefore only stable within a process — + /// that is all the id is used for. + let id: String let payload: Payload let streamFactory: (@Sendable () async -> AsyncStream)? + /// Whether a `.stream` input's id was derived from a caller-provided + /// identity (stable across re-renders) rather than minted per + /// construction. `PicoMarkdownView` keys its consume task off `id` + /// directly when this is true, so changing the caller's stream identity + /// restarts consumption with the new factory. + let hasStableStreamID: Bool - private init(id: UUID = UUID(), payload: Payload, streamFactory: (@Sendable () async -> AsyncStream)? = nil) { + private init(id: String, + payload: Payload, + streamFactory: (@Sendable () async -> AsyncStream)? = nil, + hasStableStreamID: Bool = false) { self.id = id self.payload = payload self.streamFactory = streamFactory + self.hasStableStreamID = hasStableStreamID } public static func text(_ value: String) -> MarkdownStreamingInput { - MarkdownStreamingInput(payload: .replacement(value)) + var hasher = Hasher() + hasher.combine(value) + return MarkdownStreamingInput(id: "text-\(value.count)-\(hasher.finalize())", + payload: .replacement(value)) } public static func chunks(_ values: [String]) -> MarkdownStreamingInput { - MarkdownStreamingInput(payload: .chunks(values)) + var hasher = Hasher() + hasher.combine(values.count) + for value in values { + hasher.combine(value) + } + return MarkdownStreamingInput(id: "chunks-\(values.count)-\(hasher.finalize())", + payload: .chunks(values)) } - public static func stream(_ factory: @escaping @Sendable () async -> AsyncStream) -> MarkdownStreamingInput { - MarkdownStreamingInput(payload: .stream, streamFactory: factory) + /// - Parameter id: Optional caller-provided identity for the stream. + /// Provide one when the same view identity may receive different + /// streams over time (e.g. regenerating a response in place): changing + /// the identity restarts consumption with the new factory, while equal + /// identities survive re-renders without a restart. When omitted, the + /// stream is consumed once per view identity. + public static func stream(_ factory: @escaping @Sendable () async -> AsyncStream, + id: AnyHashable? = nil) -> MarkdownStreamingInput { + if let id { + var hasher = Hasher() + hasher.combine(id) + return MarkdownStreamingInput(id: "stream-client-\(hasher.finalize())", + payload: .stream, + streamFactory: factory, + hasStableStreamID: true) + } + return MarkdownStreamingInput(id: "stream-\(UUID().uuidString)", payload: .stream, streamFactory: factory) + } + + var isStream: Bool { + if case .stream = payload { + return true + } + return false } } diff --git a/Sources/PicoMarkdownView/Views/MarkdownStreamingViewModel.swift b/Sources/PicoMarkdownView/Views/MarkdownStreamingViewModel.swift index 67853de..60cea60 100644 --- a/Sources/PicoMarkdownView/Views/MarkdownStreamingViewModel.swift +++ b/Sources/PicoMarkdownView/Views/MarkdownStreamingViewModel.swift @@ -8,7 +8,17 @@ final class MarkdownStreamingViewModel { private static let logger = Logger(subsystem: "com.picomarkdown", category: "ViewModel") private var pipeline: MarkdownStreamingPipeline - private var processedInputs: Set = [] + private var pipelineGeneration: UInt64 = 0 + /// Id of the input whose consumption most recently *started* — intent, + /// not completion. Recording at start is safe because `replace(with:)` + /// and `consume(chunks:)` always run to completion once started (plain + /// awaits do not abort on task cancellation); the only thing that stops + /// them is a newer input, which overwrites this id and wins the pipeline + /// generation check. Comparing against intent also means an A -> B -> A + /// flip while B is still parsing restarts A instead of dropping it as a + /// duplicate of stale state. + private var activeInputID: String? + private var lastReplacementValue: String? private let theme: MarkdownRenderTheme private let imageProvider: MarkdownImageProvider? private let tagPrefixes: Set @@ -38,52 +48,117 @@ final class MarkdownStreamingViewModel { } func consume(_ input: MarkdownStreamingInput) async { - if case .replacement = input.payload { - processedInputs.removeAll(keepingCapacity: true) - } - guard processedInputs.insert(input.id).inserted else { return } switch input.payload { case .replacement(let value): + // Input ids are content-derived for `.text`/`.chunks`, so a + // re-fired `.task` (parent re-render, scroll-back in a lazy + // container) with unchanged content is dropped here without + // touching the pipeline. Only the most recently *started* id is + // remembered (see `activeInputID`) so A -> B -> A re-applies A + // even while B is still mid-parse. + guard input.id != activeInputID else { return } + activeInputID = input.id await replace(with: value) case .chunks(let values): + guard input.id != activeInputID else { return } + activeInputID = input.id await consume(chunks: values) case .stream: + // Streams are intentionally NOT deduplicated by id: `.task` is + // cancelled when the view scrolls out and re-fires with the same + // id when it reappears, and a cancelled stream cannot be resumed. + // Rebuild and re-invoke the factory so the view shows the full + // content again (the factory should return the full stream on + // each invocation). guard let factory = input.streamFactory else { return } + // Streams still update the active id: a `.chunks`/`.text` input + // that re-arrives after a stream replaced the document must not + // be mistaken for a redundant re-delivery. + activeInputID = input.id + let (freshPipeline, generation) = makeFreshPipeline() + _ = await freshPipeline.updateMermaidContentWidth(mermaidContentWidth) + enqueueUpdate(blocks: [], diff: nil) let stream = await factory() - await consume(stream: stream) + await consume(stream: stream, pipeline: freshPipeline, generation: generation) } } + /// Replaces the active pipeline and bumps the consumption generation so a + /// cancelled-but-still-draining older consume loop can no longer publish + /// into the new document. + private func makeFreshPipeline() -> (MarkdownStreamingPipeline, UInt64) { + lastReplacementValue = nil + resetImagePrefetchState() + pipelineGeneration &+= 1 + let newPipeline = MarkdownStreamingPipeline(theme: theme, imageProvider: imageProvider, tagPrefixes: tagPrefixes) + pipeline = newPipeline + return (newPipeline, pipelineGeneration) + } + private func consume(chunks: [String]) async { - for chunk in chunks { - await applyChunk(chunk) + // A `.chunks` input describes a complete document, and `finish()` has + // already sealed any previously consumed input. Feeding into the + // existing pipeline would duplicate content, so rebuild from scratch — + // but still publish after every chunk so long replays render (and + // start image prefetch) progressively rather than all at once. + let (freshPipeline, generation) = makeFreshPipeline() + _ = await freshPipeline.updateMermaidContentWidth(mermaidContentWidth) + + // The first publish must be a full replace (diff: nil): the view may + // still show the previous document, and the fresh pipeline's diffs + // are relative to an empty one. + var publishedAny = false + for chunk in chunks where !chunk.isEmpty { + if let update = await freshPipeline.feed(chunk) { + guard generation == pipelineGeneration else { return } + enqueueUpdate(blocks: update.blocks, diff: publishedAny ? update.diff : nil) + publishedAny = true + } } - if let update = await pipeline.finish() { - enqueueUpdate(blocks: update.blocks, diff: update.diff) + if let update = await freshPipeline.finish() { + guard generation == pipelineGeneration else { return } + enqueueUpdate(blocks: update.blocks, diff: publishedAny ? update.diff : nil) + publishedAny = true + } + guard generation == pipelineGeneration else { return } + if !publishedAny { + // Empty input still replaces whatever was on screen. + enqueueUpdate(blocks: [], diff: nil) } } - private func consume(stream: AsyncStream) async { + private func consume(stream: AsyncStream, + pipeline: MarkdownStreamingPipeline, + generation: UInt64) async { for await chunk in stream { - await applyChunk(chunk) + if Task.isCancelled { return } + guard !chunk.isEmpty else { continue } + if let update = await pipeline.feed(chunk) { + guard generation == pipelineGeneration else { return } + enqueueUpdate(blocks: update.blocks, diff: update.diff) + } } + guard !Task.isCancelled else { return } if let update = await pipeline.finish() { + guard generation == pipelineGeneration else { return } enqueueUpdate(blocks: update.blocks, diff: update.diff) } } private func replace(with value: String) async { + // Belt-and-braces alongside the content-derived input id: a redundant + // replace with identical text must not re-tokenize the document. + guard value != lastReplacementValue else { return } #if DEBUG Self.logger.debug("replace(with:) called, value length=\(value.count)") #endif - resetImagePrefetchState() - let newPipeline = MarkdownStreamingPipeline(theme: theme, imageProvider: imageProvider, tagPrefixes: tagPrefixes) + let (freshPipeline, generation) = makeFreshPipeline() var latestBlocks: [RenderedBlock] = [] - _ = await newPipeline.updateMermaidContentWidth(mermaidContentWidth) + _ = await freshPipeline.updateMermaidContentWidth(mermaidContentWidth) if !value.isEmpty { - if let update = await newPipeline.feed(value) { + if let update = await freshPipeline.feed(value) { latestBlocks = update.blocks #if DEBUG Self.logger.debug("feed produced \(latestBlocks.count) blocks") @@ -95,7 +170,7 @@ final class MarkdownStreamingViewModel { } } - if let update = await newPipeline.finish() { + if let update = await freshPipeline.finish() { latestBlocks = update.blocks #if DEBUG Self.logger.debug("finish produced \(latestBlocks.count) blocks") @@ -106,20 +181,14 @@ final class MarkdownStreamingViewModel { #endif } - pipeline = newPipeline + guard generation == pipelineGeneration else { return } + lastReplacementValue = value #if DEBUG Self.logger.debug("enqueueUpdate with \(latestBlocks.count) blocks") #endif enqueueUpdate(blocks: latestBlocks, diff: nil) } - private func applyChunk(_ chunk: String) async { - guard !chunk.isEmpty else { return } - if let update = await pipeline.feed(chunk) { - enqueueUpdate(blocks: update.blocks, diff: update.diff) - } - } - func updateMermaidContentWidth(_ width: CGFloat?) async { let normalizedWidth: CGFloat? = { guard let width, width > 0 else { return nil } @@ -195,6 +264,13 @@ final class MarkdownStreamingViewModel { } private func updateImageDependencies(using blocks: [RenderedBlock]) { + // This runs once per enqueued update (i.e. per chunk). The common case + // is a document with no images at all — skip the dictionary rebuild + // entirely rather than reallocating an empty map every chunk. + if imageBlockDependencies.isEmpty && blocks.allSatisfy({ $0.images.isEmpty }) { + return + } + var dependencies: [URL: Set] = [:] for block in blocks { for image in block.images { @@ -214,9 +290,15 @@ final class MarkdownStreamingViewModel { private func scheduleImagePrefetchIfNeeded(using blocks: [RenderedBlock]) { guard !blocks.isEmpty else { return } + guard !imageBlockDependencies.isEmpty else { return } guard let prefetcher = imageProvider as? any MarkdownImagePrefetchingProvider else { return } - for url in imageBlockDependencies.keys.sorted(by: { $0.absoluteString < $1.absoluteString }) { + // Sort only the not-yet-requested URLs (usually none) instead of + // re-sorting the full set on every chunk. + let pendingURLs = imageBlockDependencies.keys.filter { !requestedRemoteImageURLs.contains($0) } + guard !pendingURLs.isEmpty else { return } + + for url in pendingURLs.sorted(by: { $0.absoluteString < $1.absoluteString }) { guard requestedRemoteImageURLs.insert(url).inserted else { continue } let generation = imagePrefetchGeneration imagePrefetchTasks[url] = Task { [weak self] in diff --git a/Sources/PicoMarkdownView/Views/PicoMarkdownView.swift b/Sources/PicoMarkdownView/Views/PicoMarkdownView.swift index 94630f1..af34375 100644 --- a/Sources/PicoMarkdownView/Views/PicoMarkdownView.swift +++ b/Sources/PicoMarkdownView/Views/PicoMarkdownView.swift @@ -6,6 +6,16 @@ public struct PicoMarkdownView: View { private let configuration: PicoTextKitConfiguration @State private var viewModel: MarkdownStreamingViewModel + /// Stable per-view-identity token used as the `.task` id for `.stream` + /// inputs. A stream input mints a unique id on every construction + /// (closures have no comparable content), so keying the task off + /// `input.id` would cancel consumption and re-invoke the factory on every + /// parent body re-evaluation. Keying it off this token keeps the task + /// alive across re-renders, restarts it when the view identity changes, + /// and — because non-stream inputs keep their content-derived id — also + /// restarts it when the input switches between stream and non-stream + /// modes, always consuming the *current* input. + @State private var streamTaskIdentity = UUID() @StateObject private var controller = TextKitStreamingController() @Environment(\.openURL) private var openURL @Environment(\.picoOnTagTap) private var onTagTap @@ -23,6 +33,15 @@ public struct PicoMarkdownView: View { _viewModel = State(initialValue: MarkdownStreamingViewModel(theme: theme, imageProvider: imageProvider, tagPrefixes: tagPrefixes)) } + private var consumeTaskID: String { + guard input.isStream else { return input.id } + // A caller-provided stream identity is stable across re-renders and + // changes exactly when the caller wants a restart (e.g. regenerating + // a response in place), so it can key the task directly. Without one, + // fall back to the per-view-identity token. + return input.hasStableStreamID ? input.id : "stream-identity-\(streamTaskIdentity.uuidString)" + } + /// Creates a view that renders `text` as Markdown. /// /// - Important: `theme`, `imageProvider`, and `tagPrefixes` are @@ -53,6 +72,15 @@ public struct PicoMarkdownView: View { configuration: configuration) } + /// Creates a view that renders a complete document delivered as an array + /// of chunks. + /// + /// - Important: This is a **one-shot** convenience — for example, + /// replaying the collected chunks of a finished LLM response. Each + /// delivery is parsed as a complete document, so passing a *growing* + /// array re-parses the whole document on every append. For live + /// streaming, use the `stream:` initializer, which feeds the pipeline + /// incrementally with O(chunk) work per chunk. public init(chunks: [String], theme: MarkdownRenderTheme = .default(), imageProvider: MarkdownImageProvider? = nil, @@ -66,13 +94,34 @@ public struct PicoMarkdownView: View { configuration: configuration) } + /// Creates a view that renders an async stream of Markdown chunks. + /// + /// - Important: The factory may be invoked more than once for the same + /// view. The consuming task is cancelled when the view leaves the + /// hierarchy (e.g. scrolls out of a lazy container) and, because a + /// cancelled stream cannot be resumed, the factory is called again when + /// the view reappears. Return the full stream from the beginning on + /// every invocation (for a finished LLM response, replay the collected + /// text) so reappearing views render complete content. + /// + /// - Parameter streamID: Optional identity for the stream. Without it, + /// the stream is consumed once per view identity, so swapping in a + /// *different* factory during a re-render is ignored (closures cannot + /// be compared). Pass a value that changes when the stream's content + /// changes — e.g. a regeneration counter — to restart consumption with + /// the new factory while equal values continue to survive re-renders: + /// + /// ```swift + /// PicoMarkdownView(stream: makeStream, streamID: message.generationID) + /// ``` public init(stream: @escaping @Sendable () async -> AsyncStream, + streamID: AnyHashable? = nil, theme: MarkdownRenderTheme = .default(), imageProvider: MarkdownImageProvider? = nil, remoteImagesEnabled: Bool = true, tagPrefixes: Set = TagPrefix.defaults, configuration: PicoTextKitConfiguration = .default()) { - self.init(input: .stream(stream), + self.init(input: .stream(stream, id: streamID), theme: theme, imageProvider: Self.resolveImageProvider(imageProvider, remoteImagesEnabled: remoteImagesEnabled), tagPrefixes: tagPrefixes, @@ -93,7 +142,7 @@ public struct PicoMarkdownView: View { onContentSize: onContentSize, linkHandler: makeLinkHandler(), hoverHandler: makeHoverHandler()) - .task(id: input.id) { + .task(id: consumeTaskID) { await viewModel.consume(input) } } diff --git a/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift b/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift index 8fa7ca6..c0442da 100644 --- a/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift +++ b/Sources/PicoMarkdownView/Views/TextKitStreamingController.swift @@ -816,8 +816,10 @@ private final class StreamingTextKit2View: UITextView, UITextViewDelegate { init(backend: TextKitStreamingBackend) { super.init(frame: .zero, textContainer: nil) + // UITextView has no `textContentStorage` accessor (that is NSTextView + // API); reach the storage through the layout manager's content manager. if let layoutManager = textLayoutManager, - let contentStorage = textContentStorage { + let contentStorage = layoutManager.textContentManager as? NSTextContentStorage { backend.connect(to: contentStorage, layoutManager: layoutManager) } delegate = self diff --git a/Tests/PicoMarkdownViewBenchmarks/AssemblerBenchmarks.swift b/Tests/PicoMarkdownViewBenchmarks/AssemblerBenchmarks.swift index 782a2eb..b6f6420 100644 --- a/Tests/PicoMarkdownViewBenchmarks/AssemblerBenchmarks.swift +++ b/Tests/PicoMarkdownViewBenchmarks/AssemblerBenchmarks.swift @@ -145,7 +145,9 @@ private struct Metrics { addBytes(textChunk.utf8.count, to: id) case .tableHeaderCandidate(let id, let cells): let added = cells.reduce(into: 0) { total, cell in - total += cell.text.utf8.count + for run in cell { + total += run.text.utf8.count + } } addBytes(added, to: id) case .tableAppendRow(let id, let cells): diff --git a/Tests/PicoMarkdownViewTests/Assembler/MarkdownAssemblerTests.swift b/Tests/PicoMarkdownViewTests/Assembler/MarkdownAssemblerTests.swift index ca7c982..1af3f93 100644 --- a/Tests/PicoMarkdownViewTests/Assembler/MarkdownAssemblerTests.swift +++ b/Tests/PicoMarkdownViewTests/Assembler/MarkdownAssemblerTests.swift @@ -339,7 +339,7 @@ struct MarkdownAssemblerTests { let first = ChunkResult( events: [ .blockStart(id: 21, kind: .table), - .tableHeaderCandidate(id: 21, cells: [InlineRun(text: "H1"), InlineRun(text: "H2")]) + .tableHeaderCandidate(id: 21, cells: [[InlineRun(text: "H1")], [InlineRun(text: "H2")]]) ], openBlocks: [OpenBlockState(id: 21, kind: .table)] ) @@ -492,7 +492,7 @@ struct MarkdownAssemblerTests { events: [ .blockAppendFencedCode(id: 51, textChunk: "print(2)\n"), .blockStart(id: 52, kind: .table), - .tableHeaderCandidate(id: 52, cells: [InlineRun(text: "H")]), + .tableHeaderCandidate(id: 52, cells: [[InlineRun(text: "H")]]), .tableHeaderConfirmed(id: 52, alignments: [.center]), .tableAppendRow(id: 52, cells: [[InlineRun(text: "V")]]) ], @@ -699,7 +699,7 @@ struct MarkdownAssemblerTests { _ = await assembler.apply(.init( events: [ .blockStart(id: 500, kind: .table), - .tableHeaderCandidate(id: 500, cells: [InlineRun(text: "H1"), InlineRun(text: "H2")]) + .tableHeaderCandidate(id: 500, cells: [[InlineRun(text: "H1")], [InlineRun(text: "H2")]]) ], openBlocks: [OpenBlockState(id: 500, kind: .table)] )) diff --git a/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift b/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift index 3eb982d..27f1bdd 100644 --- a/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift +++ b/Tests/PicoMarkdownViewTests/MarkdownTokenizerGoldenTests.swift @@ -603,16 +603,15 @@ struct MarkdownTokenizerGoldenTests { let first = await tokenizer.feed("| Col A | Col B |\n") assertChunk(first, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("Col A"), plain("Col B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let second = await tokenizer.feed("| --- | :---: |\n") assertChunk(second, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("Col A")], [plain("Col B")]]), .tableHeaderConfirmed(.table, alignments: [.left, .center]) ], openBlocks: [.table] @@ -636,16 +635,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| Timeline |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("Timeline")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| --- |\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("Timeline")]]), .tableHeaderConfirmed(.table, alignments: [.left]) ], openBlocks: [.table] @@ -668,11 +666,8 @@ struct MarkdownTokenizerGoldenTests { let first = await tokenizer.feed("| Col A | Col B |\n") assertChunk(first, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("Col A"), plain("Col B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let second = await tokenizer.feed("Paragraph continuation\n\n") @@ -693,16 +688,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| Name | Value |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("Name"), plain("Value")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| --- | --- |\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("Name")], [plain("Value")]]), .tableHeaderConfirmed(.table, alignments: [.left, .left]) ], openBlocks: [.table] @@ -725,11 +719,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| A | B |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("A"), plain("B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| - | -- |\n\n") @@ -750,11 +741,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| A | B |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("A"), plain("B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| :-- | --: |\n\n") @@ -776,11 +764,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| A | B |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("A"), plain("B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("\(separatorLine)\n\n") @@ -805,11 +790,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| A | B |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("A"), plain("B")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| : -- : | --- |\n\n") @@ -831,16 +813,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| L | R |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("L"), plain("R")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| :--- | ---: |\n\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("L")], [plain("R")]]), .tableHeaderConfirmed(.table, alignments: [.left, .right]), .blockEnd(.table) ], @@ -854,16 +835,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| C1 | C2 |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("C1"), plain("C2")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| :---: | :---: |\n\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("C1")], [plain("C2")]]), .tableHeaderConfirmed(.table, alignments: [.center, .center]), .blockEnd(.table) ], @@ -879,16 +859,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| a \\| b | c |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("a | b"), plain("c")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| --- | --- |\n\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("a | b")], [plain("c")]]), .tableHeaderConfirmed(.table, alignments: [.left, .left]), .blockEnd(.table) ], @@ -903,11 +882,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| H1 | H2 |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("H1"), plain("H2")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let row = await tokenizer.feed("| foo | bar |\n\n") @@ -928,11 +904,8 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| X | Y |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("X"), plain("Y")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| -- | -- |\n\n") @@ -953,16 +926,15 @@ struct MarkdownTokenizerGoldenTests { let header = await tokenizer.feed("| H1 | H2 | H3 | H4 |\n") assertChunk(header, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("H1"), plain("H2"), plain("H3"), plain("H4")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let separator = await tokenizer.feed("| :--- | ---: | :---: | --- |\n") assertChunk(separator, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("H1")], [plain("H2")], [plain("H3")], [plain("H4")]]), .tableHeaderConfirmed(.table, alignments: [.left, .right, .center, .left]) ], openBlocks: [.table] @@ -2409,16 +2381,15 @@ struct MarkdownTokenizerGoldenTests { let third = await tokenizer.feed(" | Temp |\n") assertChunk(third, matches: .init( - events: [ - .blockStart(.table), - .tableHeaderCandidate(.table, cells: [plain("Month"), plain("Temp")]) - ], - openBlocks: [.table] + events: [], + openBlocks: [] ), state: &state) let fourth = await tokenizer.feed("| --- | --- |\n") assertChunk(fourth, matches: .init( events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [[plain("Month")], [plain("Temp")]]), .tableHeaderConfirmed(.table, alignments: [.left, .left]) ], openBlocks: [.table] @@ -2434,6 +2405,85 @@ struct MarkdownTokenizerGoldenTests { ), state: &state) } + @Test("Table header cells receive inline formatting") + func tableHeaderCellsReceiveInlineFormatting() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + let header = await tokenizer.feed("| **Bold** | `code` |\n") + assertChunk(header, matches: .init( + events: [], + openBlocks: [] + ), state: &state) + + let separator = await tokenizer.feed("| --- | --- |\n") + assertChunk(separator, matches: .init( + events: [ + .blockStart(.table), + .tableHeaderCandidate(.table, cells: [ + [InlineRunShape(text: "Bold", style: InlineStyle.bold)], + [InlineRunShape(text: "code", style: InlineStyle.code)] + ]), + .tableHeaderConfirmed(.table, alignments: [.left, .left]) + ], + openBlocks: [.table] + ), state: &state) + + let row = await tokenizer.feed("| a | b |\n\n") + assertChunk(row, matches: .init( + events: [ + .tableAppendRow(.table, cells: [[plain("a")], [plain("b")]]), + .blockEnd(.table) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Cross-chunk table degradation leaves earlier blocks intact") + func crossChunkTableDegradationLeavesEarlierBlocksIntact() async { + let tokenizer = MarkdownTokenizer() + var state = EventNormalizationState() + + // Paragraph and table header candidate arrive in one chunk; the + // candidate must not be announced until its separator confirms. + let first = await tokenizer.feed("Intro\n\n| A | B |\n") + assertChunk(first, matches: .init( + events: [ + .blockStart(.paragraph), + .blockAppendInline(.paragraph, runs: [plain("Intro")]), + .blockEnd(.paragraph) + ], + openBlocks: [] + ), state: &state) + + // The follow-up line invalidates the candidate in a *later* chunk. + // Degradation must replay the buffered lines without retracting or + // duplicating any previously delivered events. + let second = await tokenizer.feed("not a separator\n\n") + assertChunk(second, matches: .init( + events: [ + .blockStart(.unknown), + .blockAppendInline(.unknown, runs: [plain("| A | B |\nnot a separator\n")]), + .blockEnd(.unknown) + ], + openBlocks: [] + ), state: &state) + } + + @Test("Table document is split-invariant at every chunk boundary") + func tableChunkSplitEquivalence() async { + let markdown = "Intro\n\n| **Bold** | `code` |\n| --- | :---: |\n| a | b |\n| c | d |\n\nOutro\n\n" + let single = summarizeBlocks(from: await collectEvents(chunks: [markdown])) + for index in markdown.indices.dropFirst() { + let streamed = summarizeBlocks(from: await collectEvents(chunks: [ + String(markdown[..