Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: [
Expand All @@ -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]
)
5 changes: 4 additions & 1 deletion Sources/Litext/LTXLabel/LTXLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion Sources/Litext/LTXLabel/TextLayout/LTXLabel+Reveal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions Sources/MarkdownView/Components/CodeView/CodeHighlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSNumber, HighlightMapBox> = {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>

Expand Down
65 changes: 36 additions & 29 deletions Sources/MarkdownView/MarkdownTextBuilder/PreprocessedContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand All @@ -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),
Expand Down Expand Up @@ -172,7 +177,7 @@ public extension MarkdownTextView {
}
}

private func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set<String>) {
private nonisolated func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set<String>) {
for block in blocks {
switch block {
case let .paragraph(inlines):
Expand All @@ -193,7 +198,7 @@ private func visitImageURLs(in blocks: [MarkdownBlockNode], urls: inout Set<Stri
}
}

private func visitInlineImages(in inlines: [MarkdownInlineNode], urls: inout Set<String>) {
private nonisolated func visitInlineImages(in inlines: [MarkdownInlineNode], urls: inout Set<String>) {
for inline in inlines {
switch inline {
case let .image(source, _):
Expand Down Expand Up @@ -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
) {
Expand All @@ -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 }
Expand All @@ -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] {
Expand All @@ -283,7 +290,7 @@ private func retainedHighlightMaps(
return retained
}

private func retainedDiffRenderBlocks(
private nonisolated func retainedDiffRenderBlocks(
from blocks: [MarkdownBlockNode],
available: [Int: DiffRenderBlock]
) -> [Int: DiffRenderBlock] {
Expand All @@ -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 }
Expand All @@ -311,15 +318,15 @@ 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 {
let usedIdentifiers = collectMathReplacementIdentifiers(in: blocks)
return available.filter { usedIdentifiers.contains($0.key) }
}

private func collectMathReplacementIdentifiers(in blocks: [MarkdownBlockNode]) -> Set<String> {
private nonisolated func collectMathReplacementIdentifiers(in blocks: [MarkdownBlockNode]) -> Set<String> {
var identifiers = Set<String>()
for block in blocks {
switch block {
Expand Down Expand Up @@ -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<String>
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Loading
Loading