Skip to content

Commit 673f9af

Browse files
authored
feat(shared-core): Swift facade for the text-format parser (#1747)
`:libs:text-format` is exported in the XCFramework, but iOS has no Swift entry point to it. `TextFormat.parse(_:ranges:)` wraps `parseTextFormat` and maps the Kotlin types to Swift: `NSRange` offsets, a `Span` enum per style, and `DisplayRange.target` as a `URL`. Offsets are UTF-16 on both sides, so they pass through unchanged. FlipcashCore already re-exports SharedCoreKit with `@_exported import`, so the new types reach it with no change on the iOS side.
1 parent d2c5cb6 commit 673f9af

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Foundation
2+
import SharedCore
3+
4+
/// Chat text markup (bold, italic, strike, code, code blocks, quotes, lists, masked links), parsed
5+
/// by the shared Kotlin in `:libs:text-format`.
6+
///
7+
/// Offsets in and out are UTF-16 code units, which is what Kotlin `String` indices and `NSRange`
8+
/// both count, so they cross the bridge unchanged.
9+
public enum TextFormat {
10+
11+
/// What a detected range is. The parser never detects these itself; callers pass them in.
12+
public enum RangeKind: Hashable, Sendable {
13+
case link
14+
case mention
15+
}
16+
17+
/// A link or mention that detection found in the raw text. A marker inside one never opens or
18+
/// closes a style.
19+
public struct ProtectedRange: Hashable, Sendable {
20+
public let range: NSRange
21+
public let kind: RangeKind
22+
23+
public init(range: NSRange, kind: RangeKind) {
24+
self.range = range
25+
self.kind = kind
26+
}
27+
}
28+
29+
/// A style over `FormattedText.display`.
30+
public enum Span: Hashable, Sendable {
31+
case bold(NSRange)
32+
case italic(NSRange)
33+
case strike(NSRange)
34+
case code(NSRange)
35+
case codeBlock(NSRange)
36+
case quote(NSRange)
37+
case bullet(NSRange)
38+
case numbered(NSRange)
39+
40+
public var range: NSRange {
41+
switch self {
42+
case .bold(let range), .italic(let range), .strike(let range), .code(let range),
43+
.codeBlock(let range), .quote(let range), .bullet(let range), .numbered(let range):
44+
return range
45+
}
46+
}
47+
}
48+
49+
/// An input range moved to `FormattedText.display` offsets. A masked link `[text](url)`
50+
/// becomes a `.link` over `text` with the URL as `target`. A range without a `target` opens
51+
/// its own text.
52+
public struct DisplayRange: Hashable, Sendable {
53+
public let range: NSRange
54+
public let kind: RangeKind
55+
public let target: URL?
56+
57+
public init(range: NSRange, kind: RangeKind, target: URL? = nil) {
58+
self.range = range
59+
self.kind = kind
60+
self.target = target
61+
}
62+
}
63+
64+
/// The parser's result. `display` is the raw text minus the consumed markers. `spans` are
65+
/// merged per style and ordered by start, then longest first. `displayRanges` are ordered by
66+
/// start, end, kind, then target.
67+
public struct FormattedText: Hashable, Sendable {
68+
public let display: String
69+
public let spans: [Span]
70+
public let displayRanges: [DisplayRange]
71+
}
72+
73+
/// Parses `text`. `ranges` are the links and mentions detection found in it.
74+
public static func parse(_ text: String, ranges: [ProtectedRange] = []) -> FormattedText {
75+
let result = SharedCore.TextFormatParserKt.parseTextFormat(
76+
text: text,
77+
ranges: ranges.map {
78+
SharedCore.ProtectedRange(
79+
start: Int32($0.range.location),
80+
end: Int32(NSMaxRange($0.range)),
81+
kind: $0.kind.kotlin
82+
)
83+
}
84+
)
85+
return FormattedText(
86+
display: result.display,
87+
spans: result.spans.map(Span.init),
88+
displayRanges: result.displayRanges.map(DisplayRange.init)
89+
)
90+
}
91+
}
92+
93+
extension TextFormat.RangeKind {
94+
95+
var kotlin: SharedCore.RangeKind {
96+
switch self {
97+
case .link: return .link
98+
case .mention: return .mention
99+
}
100+
}
101+
102+
// Kotlin enums arrive as classes, so the switch cannot be exhaustive. A case added in Kotlin
103+
// without one here is a programming error, and `TextFormatTests` walks `entries` to catch it.
104+
init(_ kind: SharedCore.RangeKind) {
105+
switch kind {
106+
case .link: self = .link
107+
case .mention: self = .mention
108+
default: preconditionFailure("Unmapped RangeKind \(kind.name)")
109+
}
110+
}
111+
}
112+
113+
extension TextFormat.Span {
114+
115+
init(_ span: SharedCore.StyledSpan) {
116+
let range = NSRange(location: Int(span.start), length: Int(span.end - span.start))
117+
switch span.style {
118+
case .bold: self = .bold(range)
119+
case .italic: self = .italic(range)
120+
case .strike: self = .strike(range)
121+
case .code: self = .code(range)
122+
case .codeblock: self = .codeBlock(range)
123+
case .quote: self = .quote(range)
124+
case .bullet: self = .bullet(range)
125+
case .numbered: self = .numbered(range)
126+
default: preconditionFailure("Unmapped FormatStyle \(span.style.name)")
127+
}
128+
}
129+
}
130+
131+
extension TextFormat.DisplayRange {
132+
133+
init(_ range: SharedCore.DisplayRange) {
134+
self.init(
135+
range: NSRange(location: Int(range.start), length: Int(range.end - range.start)),
136+
kind: TextFormat.RangeKind(range.kind),
137+
target: range.target.flatMap(URL.init(string:))
138+
)
139+
}
140+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Foundation
2+
import SharedCore
3+
import Testing
4+
@testable import SharedCoreKit
5+
6+
@Suite struct TextFormatTests {
7+
8+
@Test func stripsTheMarkersAroundBold() {
9+
let result = TextFormat.parse("*bold*")
10+
#expect(result.display == "bold")
11+
#expect(result.spans == [.bold(NSRange(location: 0, length: 4))])
12+
#expect(result.displayRanges.isEmpty)
13+
}
14+
15+
/// Offsets are UTF-16 on both sides; an emoji is two code units and must not shift the span.
16+
@Test func countsOffsetsInUTF16() {
17+
let result = TextFormat.parse("😀 *b*")
18+
#expect(result.display == "😀 b")
19+
#expect(result.spans == [.bold(NSRange(location: 3, length: 1))])
20+
}
21+
22+
@Test func turnsAMaskedLinkIntoALinkOverItsText() {
23+
let text = "[docs](https://example.com)"
24+
let url = (text as NSString).range(of: "https://example.com")
25+
let result = TextFormat.parse(text, ranges: [.init(range: url, kind: .link)])
26+
#expect(result.display == "docs")
27+
#expect(result.displayRanges == [
28+
.init(range: NSRange(location: 0, length: 4), kind: .link, target: URL(string: "https://example.com")),
29+
])
30+
}
31+
32+
@Test func leavesMarkersInsideAMentionAlone() {
33+
let text = "@a*b*"
34+
let result = TextFormat.parse(text, ranges: [.init(range: NSRange(location: 0, length: 5), kind: .mention)])
35+
#expect(result.display == text)
36+
#expect(result.spans.isEmpty)
37+
#expect(result.displayRanges == [.init(range: NSRange(location: 0, length: 5), kind: .mention)])
38+
}
39+
40+
/// A style or kind added in Kotlin compiles fine on both sides and would trap in the mapping.
41+
@Test func mapsEveryKotlinCase() {
42+
let styles = Set(SharedCore.FormatStyle.entries.map {
43+
TextFormat.Span(SharedCore.StyledSpan(start: 0, end: 1, style: $0))
44+
})
45+
#expect(styles.count == SharedCore.FormatStyle.entries.count)
46+
47+
let kinds = Set(SharedCore.RangeKind.entries.map(TextFormat.RangeKind.init))
48+
#expect(kinds.count == SharedCore.RangeKind.entries.count)
49+
}
50+
}

0 commit comments

Comments
 (0)