Skip to content

Commit c4f528d

Browse files
authored
Merge pull request #3 from bisonbet/pr-38-swift6-fix
Fix Swift 6 archive error in TextualNamespace init
2 parents ded6ebb + 8b1e47a commit c4f528d

4 files changed

Lines changed: 209 additions & 16 deletions

File tree

Sources/Textual/Internal/TextFragment/TextBuilder.swift

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@ import SwiftUI
1515
// Runs with attachments are converted to placeholder images sized by the attachment's
1616
// sizeThatFits(_:in:) result. Placeholders are tagged with AttachmentAttribute so overlays
1717
// can identify and render the actual attachment views at the resolved layout positions.
18+
//
19+
// MARK: - Recursion-safe Text construction
20+
//
21+
// Only attachment placeholders and link runs need to be standalone Text nodes — they
22+
// rely on `customAttribute(AttachmentAttribute(...))` and `customAttribute(LinkAttribute(...))`
23+
// markers that cannot live inside an AttributedString. Every other run carries standard
24+
// AttributedString attributes (foregroundColor, font, presentationIntent, syntax-highlight
25+
// theme tokens, etc.) which Text(_ attributedString:) preserves natively.
26+
//
27+
// Building one Text per run and then `Text("\(prev)\(next)")`-reducing them into a single
28+
// value produces a LocalizedTextStorage tree whose depth equals the run count. SwiftUI
29+
// resolves that tree recursively at layout time, so a code block with thousands of Prism
30+
// tokens (or any AttributedString with thousands of runs) blows the main-thread stack at
31+
// resolve time — the "Thread stack size exceeded due to excessive recursion" crash.
32+
//
33+
// Instead, consecutive plain runs are coalesced into a single Text(_ attributedString:)
34+
// and concatenated with the `+` operator only across attachment / link boundaries. The
35+
// resulting Text-node count is bounded by `attachments + links + 1` rather than the run
36+
// count, and the concatenation uses ConcatenatedTextStorage instead of LocalizedTextStorage,
37+
// which avoids the localization-machinery walk entirely.
38+
//
39+
// The final `+`-merge is balanced (pairwise) so the ConcatenatedTextStorage tree is
40+
// O(log N) deep, not O(N). A left-fold reduce would re-introduce the same crash class
41+
// in a different guise for content with thousands of attachments / links.
1842

1943
extension TextFragment {
2044
@MainActor @Observable final class TextBuilder {
@@ -64,41 +88,87 @@ extension Text {
6488
attachmentSizes: [AttachmentKey: CGSize],
6589
in environment: TextEnvironmentValues
6690
) {
67-
let textValues = attributedString.runs.map { run in
68-
var text: Text
91+
var pieces: [Text] = []
92+
var pending = AttributedString()
6993

70-
var runEnvironment = environment
71-
runEnvironment.font = run.font ?? environment.font
94+
func flushPending() {
95+
guard !pending.runs.isEmpty else { return }
96+
pieces.append(Text(pending))
97+
pending = AttributedString()
98+
}
7299

73-
let key = run.textual.attachment.map {
74-
AttachmentKey(attachment: $0, font: runEnvironment.font)
100+
for run in attributedString.runs {
101+
let attachment = run.textual.attachment
102+
let link = run.link
103+
104+
// Plain runs (no attachment, no link) carry only AttributedString attributes,
105+
// so they round-trip cleanly through Text(_ attributedString:). Coalesce them
106+
// into `pending` and only flush at attachment / link boundaries. The 99% case
107+
// (every Prism token in a code block, every styled span in prose) lands here
108+
// and never builds a runEnvironment or a separate Text node.
109+
if attachment == nil, link == nil {
110+
pending.append(AttributedString(attributedString[run.range]))
111+
continue
75112
}
76113

77-
if let key, let size = attachmentSizes[key] {
78-
// Create placeholder
114+
flushPending()
115+
116+
var runEnvironment = environment
117+
runEnvironment.font = run.font ?? environment.font
118+
119+
var text: Text
120+
if let attachment,
121+
let size = attachmentSizes[AttachmentKey(attachment: attachment, font: runEnvironment.font)]
122+
{
79123
text = Text(placeholderSize: size)
80-
.baselineOffset(key.attachment.baselineOffset(in: runEnvironment))
124+
.baselineOffset(attachment.baselineOffset(in: runEnvironment))
81125
.customAttribute(
82126
AttachmentAttribute(
83-
key.attachment,
127+
attachment,
84128
presentationIntent: run.presentationIntent
85129
)
86130
)
87131
} else {
88132
text = Text(AttributedString(attributedString[run.range]))
89133
}
90134

91-
// Add link attribute for TextLinkInteraction
92-
if let link = run.link {
135+
if let link {
93136
text = text.customAttribute(LinkAttribute(link))
94137
}
95138

96-
return text
139+
pieces.append(text)
97140
}
98141

99-
self = textValues.reduce(Text(verbatim: "")) { partialResult, text in
100-
Text("\(partialResult)\(text)")
142+
flushPending()
143+
144+
self = Text.balancedConcatenation(of: pieces)
145+
}
146+
147+
/// Pairwise merge of `pieces` using `+`, producing a `ConcatenatedTextStorage`
148+
/// tree of O(log N) depth instead of the O(N) depth a left-fold would yield.
149+
/// `+`-resolve still recurses, so a balanced tree is the difference between
150+
/// safely rendering thousands of inline pieces and overflowing the stack at
151+
/// layout time. The construction cost stays O(N).
152+
///
153+
/// Internal (rather than fileprivate) so unit tests can exercise the helper
154+
/// directly without going through full `TextBuilder` construction.
155+
static func balancedConcatenation(of pieces: [Text]) -> Text {
156+
guard !pieces.isEmpty else { return Text(verbatim: "") }
157+
var level = pieces
158+
while level.count > 1 {
159+
var next: [Text] = []
160+
next.reserveCapacity((level.count + 1) / 2)
161+
var index = 0
162+
while index + 1 < level.count {
163+
next.append(level[index] + level[index + 1])
164+
index += 2
165+
}
166+
if index < level.count {
167+
next.append(level[index])
168+
}
169+
level = next
101170
}
171+
return level[0]
102172
}
103173

104174
private init(placeholderSize size: CGSize) {

Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
// We need the selection model at text fragment level for the
2727
// text selection background and selected attachment dimming
2828
.environment(model)
29+
// Resolve geometry before distributing to the NSView overlay. Without
30+
// this, animated container resizes (e.g. NavigationSplitView sidebar
31+
// collapse) can leave the NSTextInteractionView with a stale frame,
32+
// causing it to intercept mouse events outside its visible bounds.
33+
.geometryGroup()
2934
.overlayPreferenceValue(OverflowFrameKey.self) { frames in
3035
AppKitTextInteractionOverlay(model: model, overflowFrames: frames)
3136
.onContinuousHover { phase in

Sources/Textual/TextualNamespace.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import Foundation
1919
/// Other types can opt into it by conforming to ``TextualCompatible``.
2020
@frozen
2121
public struct TextualNamespace<Base> {
22-
@usableFromInline let base: Base
22+
@usableFromInline var base: Base
2323
@inlinable public init(_ base: Base) { self.base = base }
2424
}
2525

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import SwiftUI
2+
import Testing
3+
4+
@testable import Textual
5+
6+
@MainActor
7+
extension TextFragment<AttributedString> {
8+
struct TextBuilderTests {
9+
private static func makeAttributedString(
10+
runCount: Int,
11+
colors: [Color] = [.red, .blue, .green, .orange, .purple]
12+
) -> AttributedString {
13+
var result = AttributedString()
14+
for index in 0 ..< runCount {
15+
var piece = AttributedString("token-\(index) ")
16+
piece.foregroundColor = colors[index % colors.count]
17+
result.append(piece)
18+
}
19+
return result
20+
}
21+
22+
@Test func empty() {
23+
let builder = TextFragment<AttributedString>.TextBuilder(
24+
AttributedString(),
25+
environment: TextEnvironmentValues()
26+
)
27+
#expect(type(of: builder.text) == Text.self)
28+
}
29+
30+
@Test func plainSingleRun() {
31+
let builder = TextFragment<AttributedString>.TextBuilder(
32+
AttributedString("hello world"),
33+
environment: TextEnvironmentValues()
34+
)
35+
#expect(type(of: builder.text) == Text.self)
36+
}
37+
38+
/// A pathologically large run count that would overflow the SwiftUI layout stack
39+
/// under the previous `Text("\(prev)\(next)")` reduction. We can't probe the
40+
/// resulting Text-tree depth from outside SwiftUI, but we can at least confirm that
41+
/// construction itself remains O(N) and doesn't recurse N levels — when it does,
42+
/// it shows up as `EXC_BAD_ACCESS` here, not a passing test.
43+
@Test func largeRunCountConstructsCleanly() {
44+
let attributed = Self.makeAttributedString(runCount: 5000)
45+
let builder = TextFragment<AttributedString>.TextBuilder(
46+
attributed,
47+
environment: TextEnvironmentValues()
48+
)
49+
#expect(type(of: builder.text) == Text.self)
50+
}
51+
52+
@Test func plainRunsAreCoalescedAcrossSizeChanges() {
53+
let attributed = Self.makeAttributedString(runCount: 100)
54+
let builder = TextFragment<AttributedString>.TextBuilder(
55+
attributed,
56+
environment: TextEnvironmentValues()
57+
)
58+
builder.sizeChanged(CGSize(width: 320, height: 480), environment: TextEnvironmentValues())
59+
builder.sizeChanged(CGSize(width: 480, height: 320), environment: TextEnvironmentValues())
60+
#expect(type(of: builder.text) == Text.self)
61+
}
62+
63+
@Test func runsWithLinks() {
64+
var attributed = AttributedString("Visit ")
65+
var linkRun = AttributedString("the example site")
66+
linkRun.link = URL(string: "https://example.com")
67+
attributed.append(linkRun)
68+
attributed.append(AttributedString(" for details. "))
69+
70+
var trailingLink = AttributedString("Or here")
71+
trailingLink.link = URL(string: "https://example.org")
72+
attributed.append(trailingLink)
73+
attributed.append(AttributedString("."))
74+
75+
let builder = TextFragment<AttributedString>.TextBuilder(
76+
attributed,
77+
environment: TextEnvironmentValues()
78+
)
79+
#expect(type(of: builder.text) == Text.self)
80+
}
81+
82+
/// Stress-tests the balanced concatenation path. Many link-bearing runs each force
83+
/// a separate Text node; a left-fold concat would build a `ConcatenatedTextStorage`
84+
/// tree of depth = link count and re-introduce the 2024-class recursion crash for
85+
/// pathological link-list content. Pairwise merge keeps the tree O(log N) deep.
86+
@Test func manyLinkRunsConstructCleanly() {
87+
var attributed = AttributedString()
88+
for index in 0 ..< 2000 {
89+
var prose = AttributedString("see ")
90+
prose.foregroundColor = .secondary
91+
attributed.append(prose)
92+
93+
var link = AttributedString("link-\(index)")
94+
link.link = URL(string: "https://example.com/\(index)")
95+
attributed.append(link)
96+
97+
attributed.append(AttributedString(", "))
98+
}
99+
100+
let builder = TextFragment<AttributedString>.TextBuilder(
101+
attributed,
102+
environment: TextEnvironmentValues()
103+
)
104+
#expect(type(of: builder.text) == Text.self)
105+
}
106+
107+
@Test func balancedConcatenationOfEmptyArrayIsEmpty() {
108+
let result = Text.balancedConcatenation(of: [])
109+
#expect(type(of: result) == Text.self)
110+
}
111+
112+
@Test func balancedConcatenationOfSingleElementReturnsThatElement() {
113+
let only = Text("only")
114+
let result = Text.balancedConcatenation(of: [only])
115+
#expect(type(of: result) == Text.self)
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)