From 4374a031bdf48f8c069e3dab5e72c9acbe5637a5 Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Tue, 7 Jul 2026 09:23:24 -0400 Subject: [PATCH] feat(concurrency): migrate to the Swift 6 language mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all first-party targets off the Swift 5 language mode (and consumers off `@preconcurrency import`) to full Swift 6 strict concurrency, on both AppKit and UIKit. Approach (swift-tools-version 6.2, per-target): - MarkdownParser: `.v6`, remains nonisolated Sendable data. - Litext + MarkdownView: `.v6` + `.defaultIsolation(MainActor.self)` for the UI layers. - `isolated deinit` for the two deinits that touch main-actor state (LTXLabel, MathPreviewDataSource). The main subtlety: `defaultIsolation(MainActor)` would force MarkdownView's genuine background-processing layer (the `com.markdownview.preprocessing` Combine pipeline) onto the main actor — compiling clean but trapping at runtime in `dispatch_assert_queue`. The processing subsystem is therefore kept explicitly `nonisolated`: CodeHighlighter and ImageLoader (`@unchecked Sendable`), PreprocessedContent (background init nonisolated, math members `@MainActor`), the diff/highlight helpers, and the pipeline's background stage extracted into a `nonisolated static` function. Minor public-API changes: - Removed the unused `theme:` parameter from `CodeHighlighter.highlight`. - `PreprocessedContent.init(...backgroundSafe:)` now always defers math rendering (the `backgroundSafe: false` full-render path is not expressible off the main actor; use `@MainActor init(parserResult:theme:)` instead). Tests that exercise the main-actor UI API are annotated `@MainActor`. Full suite is at parity with the base branch (the two Python-trait failures are pre-existing under `swift test`, which excludes the Python trait). Co-Authored-By: Claude Opus 4.8 --- Package.swift | 37 ++++++-- Sources/Litext/LTXLabel/LTXLabel.swift | 5 +- .../LTXLabel/TextLayout/LTXLabel+Reveal.swift | 6 +- .../Components/CodeView/CodeHighlighter.swift | 18 ++-- .../LineSelection/LineSelectionInfo.swift | 2 +- .../PreprocessedContent.swift | 65 +++++++------ .../TextBuilder+Types.swift | 2 +- .../MarkdownTextView+MathPreview.swift | 4 +- .../MarkdownTextView+Private.swift | 94 ++++++++++--------- .../Supplements/ImageLoader.swift | 11 ++- .../Supplements/LinkPayload.swift | 2 +- .../Supplements/MathRenderer.swift | 6 +- .../Supplements/RenderedTextContent.swift | 2 +- .../Supplements/UnifiedDiff.swift | 14 +-- Tests/MarkdownViewTests/ASTDiffTests.swift | 1 + Tests/MarkdownViewTests/DiffViewTests.swift | 1 + .../MarkdownViewTests/HighlighterTests.swift | 1 + .../MarkdownViewTests/PerformanceTests.swift | 1 + .../StreamingFastPathTests.swift | 1 + 19 files changed, 165 insertions(+), 108 deletions(-) diff --git a/Package.swift b/Package.swift index 70c87e0..abee4b6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -76,7 +76,12 @@ let package = Package( targets: [ .target( name: "Litext", - resources: [.process("Resources")] + resources: [.process("Resources")], + // UIView/NSView-based text label — main-actor by default. + swiftSettings: [ + .swiftLanguageMode(.v6), + .defaultIsolation(MainActor.self), + ] ), .target( name: "MarkdownView", @@ -122,12 +127,23 @@ let package = Package( .product(name: "TreeSitterYAML", package: "tree-sitter-yaml", condition: .when(traits: ["YAML"])), ], - resources: [.process("Resources")] + resources: [.process("Resources")], + // SwiftUI/UIKit rendering layer — main-actor by default. + swiftSettings: [ + .swiftLanguageMode(.v6), + .defaultIsolation(MainActor.self), + ] + ), + .target( + name: "MarkdownParser", + dependencies: [ + .product(name: "cmark-gfm", package: "swift-cmark"), + .product(name: "cmark-gfm-extensions", package: "swift-cmark"), + ], + // Pure data / parsing — safe to run off the main actor, so it stays + // nonisolated and models are `Sendable`. + swiftSettings: [.swiftLanguageMode(.v6)] ), - .target(name: "MarkdownParser", dependencies: [ - .product(name: "cmark-gfm", package: "swift-cmark"), - .product(name: "cmark-gfm-extensions", package: "swift-cmark"), - ]), .testTarget( name: "MarkdownViewTests", dependencies: [ @@ -136,8 +152,9 @@ let package = Package( ] ), ], - // Preserve the Swift 5 language mode. Bumping swift-tools-version to 6.1 - // (required for traits) would otherwise default the package to the Swift 6 - // language mode and turn pre-existing concurrency warnings into errors. + // Targets are migrated to the Swift 6 language mode individually (see each + // target's `swiftSettings`). This package default keeps any not-yet-migrated + // target in the Swift 5 mode so its pre-existing concurrency warnings don't + // become errors mid-migration. swiftLanguageModes: [.v5] ) diff --git a/Sources/Litext/LTXLabel/LTXLabel.swift b/Sources/Litext/LTXLabel/LTXLabel.swift index 4904e7d..d53c50d 100644 --- a/Sources/Litext/LTXLabel/LTXLabel.swift +++ b/Sources/Litext/LTXLabel/LTXLabel.swift @@ -228,7 +228,10 @@ public class LTXLabel: LTXPlatformView, Identifiable { fatalError() } - deinit { + // The view is `@MainActor`, but Swift 6.2 makes `deinit` nonisolated by + // default; this touches main-actor state (`attributedText`, `attachmentViews`, + // selection), so keep the deinit on the main actor. + isolated deinit { stopRevealDriver() cancelLongPressTimer() attributedText = .init() diff --git a/Sources/Litext/LTXLabel/TextLayout/LTXLabel+Reveal.swift b/Sources/Litext/LTXLabel/TextLayout/LTXLabel+Reveal.swift index fa11661..b98d03f 100644 --- a/Sources/Litext/LTXLabel/TextLayout/LTXLabel+Reveal.swift +++ b/Sources/Litext/LTXLabel/TextLayout/LTXLabel+Reveal.swift @@ -184,7 +184,11 @@ extension LTXLabel { func startRevealDriver() { guard revealTimer == nil else { return } let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - self?.stepReveal() + // Added to `RunLoop.main` below, so the callback always fires on + // the main actor — the compiler just can't prove it from here. + MainActor.assumeIsolated { + self?.stepReveal() + } } RunLoop.main.add(timer, forMode: .common) revealTimer = timer diff --git a/Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift b/Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift index 148f4df..a7ebccd 100644 --- a/Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift +++ b/Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift @@ -71,12 +71,15 @@ import SwiftTreeSitter public typealias PlatformColor = NSColor #endif -private final class HighlightMapBox { +private nonisolated final class HighlightMapBox { let value: CodeHighlighter.HighlightMap init(_ value: CodeHighlighter.HighlightMap) { self.value = value } } -public final class CodeHighlighter { +// Thread-safe syntax-highlighting service (own `parserPoolLock`/`NSCache`, runs on +// background preprocessing queues), so it opts out of the module's default +// main-actor isolation. Thread safety is enforced manually, hence `@unchecked Sendable`. +public nonisolated final class CodeHighlighter: @unchecked Sendable { public typealias HighlightMap = [NSRange: PlatformColor] private var renderCache: NSCache = { @@ -175,7 +178,7 @@ public final class CodeHighlighter { /// Factory closures that create language configurations on demand. /// Only the requested language is initialized, avoiding the cost of loading all 15 parsers. - private static let languageFactories: [String: () -> LanguageConfiguration?] = { + private nonisolated(unsafe) static let languageFactories: [String: () -> LanguageConfiguration?] = { var factories: [String: () -> LanguageConfiguration?] = [:] func register(_ aliases: [String], _ factory: @escaping () throws -> LanguageConfiguration) { @@ -288,7 +291,7 @@ public final class CodeHighlighter { }() /// Cache of already-initialized language configurations. - private static var resolvedLanguages: [String: LanguageConfiguration] = [:] + private nonisolated(unsafe) static var resolvedLanguages: [String: LanguageConfiguration] = [:] private static let resolvedLanguagesLock = NSLock() private static func languageConfiguration(for alias: String) -> LanguageConfiguration? { @@ -416,18 +419,17 @@ public final class CodeHighlighter { } public extension CodeHighlighter { - func key(for content: String, language: String?) -> Int { + nonisolated func key(for content: String, language: String?) -> Int { var hasher = Hasher() hasher.combine(content) hasher.combine(language?.lowercased() ?? "") return hasher.finalize() } - func highlight( + nonisolated func highlight( key: Int?, content: String, - language: String?, - theme: MarkdownTheme = .default + language: String? ) -> [NSRange: PlatformColor] { let key = key ?? self.key(for: content, language: language) let nsKey = NSNumber(value: key) diff --git a/Sources/MarkdownView/Components/LineSelection/LineSelectionInfo.swift b/Sources/MarkdownView/Components/LineSelection/LineSelectionInfo.swift index e7a1cbf..f07068d 100644 --- a/Sources/MarkdownView/Components/LineSelection/LineSelectionInfo.swift +++ b/Sources/MarkdownView/Components/LineSelection/LineSelectionInfo.swift @@ -1,7 +1,7 @@ import Foundation /// Information about the currently selected lines in a code or diff view. -public struct LineSelectionInfo { +public nonisolated struct LineSelectionInfo: Sendable { /// The 1-based range of selected lines. public let lineRange: ClosedRange diff --git a/Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift b/Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift index de3eec4..8b35dbc 100644 --- a/Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift +++ b/Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift @@ -10,7 +10,10 @@ import MarkdownParser import os.log public extension MarkdownTextView { - final class PreprocessedContent { + // Built on the background preprocessing queue (code highlighting, diff blocks), + // so it is nonisolated by default. Only the members that perform math rendering + // (which needs UIKit/AppKit context) are marked `@MainActor` below. + nonisolated final class PreprocessedContent { public let blocks: [MarkdownBlockNode] public let rendered: RenderedTextContent.Map public let highlightMaps: [Int: CodeHighlighter.HighlightMap] @@ -44,7 +47,7 @@ public extension MarkdownTextView { self.imageSources = imageSources } - public init(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme) { + @MainActor public init(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme) { blocks = parserResult.document rendered = parserResult.render(theme: theme) highlightMaps = parserResult.render(theme: theme) @@ -54,27 +57,29 @@ public extension MarkdownTextView { } /// Creates preprocessed content directly from markdown text, including raw unified diffs. - public convenience init(markdown: String, theme: MarkdownTheme) { + @MainActor public convenience init(markdown: String, theme: MarkdownTheme) { let normalizedMarkdown = RawDiffMarkdownNormalizer.normalizeForParsing(markdown) let parserResult = MarkdownParser().parse(normalizedMarkdown) self.init(parserResult: parserResult, theme: theme) } /// Creates preprocessed content with code highlighting done on the calling thread - /// and math rendering deferred. Use this from background queues where UIKit trait - /// access is unavailable. - public init(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme, backgroundSafe: Bool) { + /// and math rendering deferred. Safe to call from background queues where UIKit + /// trait access is unavailable. + /// + /// - Note: `backgroundSafe` is retained for source compatibility. Math rendering + /// requires the main actor and is now always deferred here — complete it with + /// `completeMathRendering(_:theme:)` on the main actor. The former synchronous + /// full-render (`backgroundSafe: false`) path is not expressible off the main + /// actor under strict concurrency; use `@MainActor init(parserResult:theme:)` + /// when a fully-rendered value is needed up front. + nonisolated public init(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme, backgroundSafe: Bool) { + _ = backgroundSafe blocks = parserResult.document - if backgroundSafe { - // Code highlighting is thread-safe; math rendering needs main thread context - highlightMaps = parserResult.render(theme: theme) - diffRenderBlocks = parserResult.renderDiffBlocks() - rendered = .init() - } else { - rendered = parserResult.render(theme: theme) - highlightMaps = parserResult.render(theme: theme) - diffRenderBlocks = parserResult.renderDiffBlocks() - } + // Code highlighting is thread-safe; math rendering is deferred to the main actor. + highlightMaps = parserResult.render(theme: theme) + diffRenderBlocks = parserResult.renderDiffBlocks() + rendered = .init() imageSources = Self.collectImageSources(in: blocks) preloadImages() } @@ -88,7 +93,7 @@ public extension MarkdownTextView { } /// Fills in math-rendered content from main thread after background init. - public func completeMathRendering(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme) -> PreprocessedContent { + @MainActor public func completeMathRendering(parserResult: MarkdownParser.ParseResult, theme: MarkdownTheme) -> PreprocessedContent { PreprocessedContent( blocks: blocks, rendered: parserResult.render(theme: theme), @@ -172,7 +177,7 @@ public extension MarkdownTextView { } } -private func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set) { +private nonisolated func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set) { for block in blocks { switch block { case let .paragraph(inlines): @@ -193,7 +198,7 @@ private func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set) { +private nonisolated func visitInlineImages(in inlines: [MarkdownInlineNode], urls: inout Set) { for inline in inlines { switch inline { case let .image(source, _): @@ -228,14 +233,16 @@ public extension MarkdownParser.ParseResult { } } - func render(theme: MarkdownTheme) -> RenderedTextContent.Map { + // Math rendering uses `MathRenderer` (UIKit/AppKit) and reads `theme` fonts/colors, + // so it is main-actor-isolated. + @MainActor func render(theme: MarkdownTheme) -> RenderedTextContent.Map { var renderedContexts: [String: RenderedTextContent] = [:] renderMathContent(theme, &renderedContexts) return renderedContexts } } -private func visitCodeBlocks( +private nonisolated func visitCodeBlocks( in blocks: [MarkdownBlockNode], visitor: (String?, String) -> Void ) { @@ -252,7 +259,7 @@ private func visitCodeBlocks( } public extension MarkdownParser.ParseResult { - func render(theme: MarkdownTheme) -> [Int: CodeHighlighter.HighlightMap] { + nonisolated func render(theme: MarkdownTheme) -> [Int: CodeHighlighter.HighlightMap] { var highlightMaps = [Int: CodeHighlighter.HighlightMap]() visitCodeBlocks(in: document) { fenceInfo, content in guard CodeBlockClassifier.diffFenceInfo(fenceInfo: fenceInfo, content: content) == nil else { return } @@ -263,12 +270,12 @@ public extension MarkdownParser.ParseResult { return highlightMaps } - internal func renderDiffBlocks() -> [Int: DiffRenderBlock] { + internal nonisolated func renderDiffBlocks() -> [Int: DiffRenderBlock] { buildDiffRenderBlocks(from: document) } } -private func retainedHighlightMaps( +private nonisolated func retainedHighlightMaps( from blocks: [MarkdownBlockNode], available: [Int: CodeHighlighter.HighlightMap] ) -> [Int: CodeHighlighter.HighlightMap] { @@ -283,7 +290,7 @@ private func retainedHighlightMaps( return retained } -private func retainedDiffRenderBlocks( +private nonisolated func retainedDiffRenderBlocks( from blocks: [MarkdownBlockNode], available: [Int: DiffRenderBlock] ) -> [Int: DiffRenderBlock] { @@ -298,7 +305,7 @@ private func retainedDiffRenderBlocks( return retained } -private func buildDiffRenderBlocks(from blocks: [MarkdownBlockNode]) -> [Int: DiffRenderBlock] { +private nonisolated func buildDiffRenderBlocks(from blocks: [MarkdownBlockNode]) -> [Int: DiffRenderBlock] { var diffRenderBlocks = [Int: DiffRenderBlock]() visitCodeBlocks(in: blocks) { fenceInfo, content in guard let diffFenceInfo = CodeBlockClassifier.diffFenceInfo(fenceInfo: fenceInfo, content: content) else { return } @@ -311,7 +318,7 @@ private func buildDiffRenderBlocks(from blocks: [MarkdownBlockNode]) -> [Int: Di return diffRenderBlocks } -private func retainedRenderedContexts( +private nonisolated func retainedRenderedContexts( from blocks: [MarkdownBlockNode], available: RenderedTextContent.Map ) -> RenderedTextContent.Map { @@ -319,7 +326,7 @@ private func retainedRenderedContexts( return available.filter { usedIdentifiers.contains($0.key) } } -private func collectMathReplacementIdentifiers(in blocks: [MarkdownBlockNode]) -> Set { +private nonisolated func collectMathReplacementIdentifiers(in blocks: [MarkdownBlockNode]) -> Set { var identifiers = Set() for block in blocks { switch block { @@ -352,7 +359,7 @@ private func collectMathReplacementIdentifiers(in blocks: [MarkdownBlockNode]) - return identifiers } -private func collectMathReplacementIdentifiers( +private nonisolated func collectMathReplacementIdentifiers( in inlines: [MarkdownInlineNode], identifiers: inout Set ) { diff --git a/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Types.swift b/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Types.swift index 4b72c4d..adc6958 100644 --- a/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Types.swift +++ b/Sources/MarkdownView/MarkdownTextBuilder/TextBuilder+Types.swift @@ -32,7 +32,7 @@ struct RenderText { // MARK: - String Extension -extension String { +nonisolated extension String { func deletingSuffix(of characterSet: CharacterSet) -> String { var result = self while let lastChar = result.last, characterSet.contains(lastChar.unicodeScalars.first!) { diff --git a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+MathPreview.swift b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+MathPreview.swift index c0f00ee..c071595 100644 --- a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+MathPreview.swift +++ b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+MathPreview.swift @@ -88,7 +88,9 @@ item } - deinit { + // Runs the main-actor `cleanup` closure; Swift 6.2 deinits are nonisolated + // by default, so keep this one on the main actor. + isolated deinit { cleanup() } } diff --git a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift index 9363070..07c1a1c 100644 --- a/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift +++ b/Sources/MarkdownView/MarkdownTextView/MarkdownTextView+Private.swift @@ -11,13 +11,15 @@ import Litext import MarkdownParser extension MarkdownTextView { - struct IncrementalParsingContext { + // Flows through the background preprocessing queue, so both the context and the + // pipeline's update enum are nonisolated (they only carry Sendable payloads). + nonisolated struct IncrementalParsingContext { let previousRawMarkdown: String let previousContent: PreprocessedContent let previousRanges: [MarkdownParser.RootBlockRange]? } - enum RawMarkdownUpdate { + nonisolated enum RawMarkdownUpdate { case ready(content: PreprocessedContent, rawMarkdown: String) case parse(markdown: String, theme: MarkdownTheme, incrementalContext: IncrementalParsingContext?) case parsedFull( @@ -40,6 +42,50 @@ extension MarkdownTextView { qos: .userInitiated ) + /// Heavy parsing + background-safe preprocessing (code highlighting, diff blocks). + /// Deliberately `nonisolated` because it executes on `preprocessingQueue`; math + /// rendering is deferred to the main actor in the downstream `.sink`. + nonisolated static func processOnPreprocessingQueue(_ update: RawMarkdownUpdate) -> RawMarkdownUpdate { + switch update { + case .ready: + return update + case let .parse(markdown, theme, incrementalContext): + let parser = MarkdownParser() + if let incrementalContext, + let result = parser.parseIncremental( + previousMarkdown: incrementalContext.previousRawMarkdown, + newMarkdown: markdown, + previousBlocks: incrementalContext.previousContent.blocks, + previousRanges: incrementalContext.previousRanges + ) { + let tailContent = PreprocessedContent( + parserResult: result.tailResult, + theme: theme, + backgroundSafe: true + ) + return .parsedIncremental( + result: result, + tailContent: tailContent, + context: incrementalContext, + theme: theme, + rawMarkdown: markdown + ) + } + + let result = parser.parse(markdown) + // Code highlighting runs on background; math rendering deferred to main + let content = PreprocessedContent(parserResult: result, theme: theme, backgroundSafe: true) + return .parsedFull( + result: result, + content: content, + theme: theme, + rawMarkdown: markdown + ) + case .parsedFull, .parsedIncremental: + return update + } + } + private static let disallowedPlainTextAppendScalars = CharacterSet( charactersIn: "\n\r`*_[]()!$<>|~\\" ) @@ -94,46 +140,10 @@ extension MarkdownTextView { ) } .receive(on: Self.preprocessingQueue) - .map { update -> RawMarkdownUpdate in - switch update { - case .ready: - return update - case let .parse(markdown, theme, incrementalContext): - let parser = MarkdownParser() - if let incrementalContext, - let result = parser.parseIncremental( - previousMarkdown: incrementalContext.previousRawMarkdown, - newMarkdown: markdown, - previousBlocks: incrementalContext.previousContent.blocks, - previousRanges: incrementalContext.previousRanges - ) { - let tailContent = PreprocessedContent( - parserResult: result.tailResult, - theme: theme, - backgroundSafe: true - ) - return .parsedIncremental( - result: result, - tailContent: tailContent, - context: incrementalContext, - theme: theme, - rawMarkdown: markdown - ) - } - - let result = parser.parse(markdown) - // Code highlighting runs on background; math rendering deferred to main - let content = PreprocessedContent(parserResult: result, theme: theme, backgroundSafe: true) - return .parsedFull( - result: result, - content: content, - theme: theme, - rawMarkdown: markdown - ) - case .parsedFull, .parsedIncremental: - return update - } - } + // Runs on `preprocessingQueue` (off the main actor), so it must be a + // `nonisolated` function — a main-actor-isolated closure here would trap + // the runtime isolation check when Combine delivers it on that queue. + .map(Self.processOnPreprocessingQueue) .receive(on: DispatchQueue.main) .sink { [weak self] update in guard let self else { return } diff --git a/Sources/MarkdownView/Supplements/ImageLoader.swift b/Sources/MarkdownView/Supplements/ImageLoader.swift index b107d56..c568d5f 100644 --- a/Sources/MarkdownView/Supplements/ImageLoader.swift +++ b/Sources/MarkdownView/Supplements/ImageLoader.swift @@ -14,7 +14,10 @@ import os.log import AppKit #endif -public final class ImageLoader { +// A thread-safe background image service (own `NSLock`, background `URLSession`), +// so it opts out of the module's default main-actor isolation. Thread safety is +// enforced manually via `lock`, hence `@unchecked Sendable`. +public nonisolated final class ImageLoader: @unchecked Sendable { public static let shared = ImageLoader() /// Posted on the main thread when an image finishes loading. @@ -41,7 +44,7 @@ public final class ImageLoader { /// otherwise fetches asynchronously and calls completion on main thread. public func loadImage( from urlString: String, - completion: @escaping (PlatformImage?) -> Void + completion: @escaping @Sendable (PlatformImage?) -> Void ) { let cacheKey = urlString as NSString @@ -98,7 +101,9 @@ public final class ImageLoader { #if DEBUG Self.log.info("loaded: \(urlString) size=\(image.size.width)x\(image.size.height)") #endif - self?.cache.setObject(image, forKey: cacheKey) + // Re-bridge from the `Sendable` `String` rather than capturing the + // non-Sendable `NSString` into this `@Sendable` closure. + self?.cache.setObject(image, forKey: urlString as NSString) DispatchQueue.main.async { completion(image) NotificationCenter.default.post( diff --git a/Sources/MarkdownView/Supplements/LinkPayload.swift b/Sources/MarkdownView/Supplements/LinkPayload.swift index 36970af..8ea10b9 100644 --- a/Sources/MarkdownView/Supplements/LinkPayload.swift +++ b/Sources/MarkdownView/Supplements/LinkPayload.swift @@ -7,7 +7,7 @@ import Foundation -public enum LinkPayload { +public nonisolated enum LinkPayload: Sendable { case url(URL) case string(String) } diff --git a/Sources/MarkdownView/Supplements/MathRenderer.swift b/Sources/MarkdownView/Supplements/MathRenderer.swift index f33e1a8..69988ba 100644 --- a/Sources/MarkdownView/Supplements/MathRenderer.swift +++ b/Sources/MarkdownView/Supplements/MathRenderer.swift @@ -38,7 +38,9 @@ public enum MathRenderer { super.init() } - override var hash: Int { + // Immutable-state overrides of `NSObject`'s nonisolated members; must be + // nonisolated too, since the type is otherwise main-actor-isolated by default. + nonisolated override var hash: Int { var hasher = Hasher() hasher.combine(latex) hasher.combine(fontSize) @@ -49,7 +51,7 @@ public enum MathRenderer { return hasher.finalize() } - override func isEqual(_ object: Any?) -> Bool { + nonisolated override func isEqual(_ object: Any?) -> Bool { guard let other = object as? CacheKey else { return false } return latex == other.latex && fontSize == other.fontSize && r == other.r && g == other.g && b == other.b && a == other.a diff --git a/Sources/MarkdownView/Supplements/RenderedTextContent.swift b/Sources/MarkdownView/Supplements/RenderedTextContent.swift index 44b1834..aa6fda0 100644 --- a/Sources/MarkdownView/Supplements/RenderedTextContent.swift +++ b/Sources/MarkdownView/Supplements/RenderedTextContent.swift @@ -13,7 +13,7 @@ import Litext import AppKit #endif -public struct RenderedTextContent { +public nonisolated struct RenderedTextContent: Sendable { public let image: PlatformImage? public let text: String diff --git a/Sources/MarkdownView/Supplements/UnifiedDiff.swift b/Sources/MarkdownView/Supplements/UnifiedDiff.swift index 6c82ae0..b5765b0 100644 --- a/Sources/MarkdownView/Supplements/UnifiedDiff.swift +++ b/Sources/MarkdownView/Supplements/UnifiedDiff.swift @@ -1,6 +1,6 @@ import Foundation -struct DiffFenceInfo: Hashable { +nonisolated struct DiffFenceInfo: Hashable { let language: String? static func parseExplicit(_ fenceInfo: String?) -> DiffFenceInfo? { @@ -52,7 +52,7 @@ struct DiffFenceInfo: Hashable { } } -enum CodeBlockClassifier { +nonisolated enum CodeBlockClassifier { static func diffFenceInfo(fenceInfo: String?, content: String) -> DiffFenceInfo? { if let explicit = DiffFenceInfo.parseExplicit(fenceInfo) { return explicit @@ -61,7 +61,7 @@ enum CodeBlockClassifier { } } -enum RawDiffMarkdownNormalizer { +nonisolated enum RawDiffMarkdownNormalizer { static func normalizeForParsing(_ markdown: String) -> String { guard UnifiedDiffParser.canRender(content: markdown, language: nil) else { return markdown @@ -72,7 +72,7 @@ enum RawDiffMarkdownNormalizer { } } -struct DiffRenderBlock { +nonisolated struct DiffRenderBlock { enum RowKind: Hashable { case fileHeader case fileMetadata @@ -96,7 +96,7 @@ struct DiffRenderBlock { let rows: [Row] } -extension DiffRenderBlock { +nonisolated extension DiffRenderBlock { static func key(for content: String, language: String?) -> Int { let normalizedContent = content.deletingSuffix(of: .newlines) var hasher = Hasher() @@ -106,7 +106,7 @@ extension DiffRenderBlock { } } -enum UnifiedDiffParser { +nonisolated enum UnifiedDiffParser { static func canRender(content: String, language: String?) -> Bool { parse(content: content.deletingSuffix(of: .newlines), language: language) != nil } @@ -123,7 +123,7 @@ enum UnifiedDiffParser { } } -private extension UnifiedDiffParser { +private nonisolated extension UnifiedDiffParser { struct ParsedBlock { let language: String? let sections: [ParsedSection] diff --git a/Tests/MarkdownViewTests/ASTDiffTests.swift b/Tests/MarkdownViewTests/ASTDiffTests.swift index 6c0e688..d11a9de 100644 --- a/Tests/MarkdownViewTests/ASTDiffTests.swift +++ b/Tests/MarkdownViewTests/ASTDiffTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import MarkdownParser @testable import MarkdownView +@MainActor final class ASTDiffTests: XCTestCase { func testDiffIdenticalKeepsOriginalIndices() { diff --git a/Tests/MarkdownViewTests/DiffViewTests.swift b/Tests/MarkdownViewTests/DiffViewTests.swift index 81174a6..ebbb811 100644 --- a/Tests/MarkdownViewTests/DiffViewTests.swift +++ b/Tests/MarkdownViewTests/DiffViewTests.swift @@ -7,6 +7,7 @@ import XCTest import UIKit #endif +@MainActor final class DiffViewTests: XCTestCase { private let parser = MarkdownParser() diff --git a/Tests/MarkdownViewTests/HighlighterTests.swift b/Tests/MarkdownViewTests/HighlighterTests.swift index ac25ce4..0c9896b 100644 --- a/Tests/MarkdownViewTests/HighlighterTests.swift +++ b/Tests/MarkdownViewTests/HighlighterTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import MarkdownParser @testable import MarkdownView +@MainActor final class HighlighterTests: XCTestCase { // MARK: - CodeHighlighter Key Generation diff --git a/Tests/MarkdownViewTests/PerformanceTests.swift b/Tests/MarkdownViewTests/PerformanceTests.swift index 2a78fe3..9c9a34d 100644 --- a/Tests/MarkdownViewTests/PerformanceTests.swift +++ b/Tests/MarkdownViewTests/PerformanceTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import MarkdownParser @testable import MarkdownView +@MainActor final class PerformanceTests: XCTestCase { // MARK: - Test Data Generators diff --git a/Tests/MarkdownViewTests/StreamingFastPathTests.swift b/Tests/MarkdownViewTests/StreamingFastPathTests.swift index acd4378..c749c1d 100644 --- a/Tests/MarkdownViewTests/StreamingFastPathTests.swift +++ b/Tests/MarkdownViewTests/StreamingFastPathTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import MarkdownParser @testable import MarkdownView +@MainActor final class StreamingFastPathTests: XCTestCase { private let parser = MarkdownParser()