From 2261e298c3f6d9088c300ae54bac90405b603214 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:52:40 -0400 Subject: [PATCH] feat(shared-core): Swift facade for the text-format parser `: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. --- .../Sources/SharedCoreKit/TextFormat.swift | 140 ++++++++++++++++++ .../SharedCoreKitTests/TextFormatTests.swift | 50 +++++++ 2 files changed, 190 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/TextFormat.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/TextFormatTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/TextFormat.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/TextFormat.swift new file mode 100644 index 0000000000..7cf7e4fc35 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/TextFormat.swift @@ -0,0 +1,140 @@ +import Foundation +import SharedCore + +/// Chat text markup (bold, italic, strike, code, code blocks, quotes, lists, masked links), parsed +/// by the shared Kotlin in `:libs:text-format`. +/// +/// Offsets in and out are UTF-16 code units, which is what Kotlin `String` indices and `NSRange` +/// both count, so they cross the bridge unchanged. +public enum TextFormat { + + /// What a detected range is. The parser never detects these itself; callers pass them in. + public enum RangeKind: Hashable, Sendable { + case link + case mention + } + + /// A link or mention that detection found in the raw text. A marker inside one never opens or + /// closes a style. + public struct ProtectedRange: Hashable, Sendable { + public let range: NSRange + public let kind: RangeKind + + public init(range: NSRange, kind: RangeKind) { + self.range = range + self.kind = kind + } + } + + /// A style over `FormattedText.display`. + public enum Span: Hashable, Sendable { + case bold(NSRange) + case italic(NSRange) + case strike(NSRange) + case code(NSRange) + case codeBlock(NSRange) + case quote(NSRange) + case bullet(NSRange) + case numbered(NSRange) + + public var range: NSRange { + switch self { + case .bold(let range), .italic(let range), .strike(let range), .code(let range), + .codeBlock(let range), .quote(let range), .bullet(let range), .numbered(let range): + return range + } + } + } + + /// An input range moved to `FormattedText.display` offsets. A masked link `[text](url)` + /// becomes a `.link` over `text` with the URL as `target`. A range without a `target` opens + /// its own text. + public struct DisplayRange: Hashable, Sendable { + public let range: NSRange + public let kind: RangeKind + public let target: URL? + + public init(range: NSRange, kind: RangeKind, target: URL? = nil) { + self.range = range + self.kind = kind + self.target = target + } + } + + /// The parser's result. `display` is the raw text minus the consumed markers. `spans` are + /// merged per style and ordered by start, then longest first. `displayRanges` are ordered by + /// start, end, kind, then target. + public struct FormattedText: Hashable, Sendable { + public let display: String + public let spans: [Span] + public let displayRanges: [DisplayRange] + } + + /// Parses `text`. `ranges` are the links and mentions detection found in it. + public static func parse(_ text: String, ranges: [ProtectedRange] = []) -> FormattedText { + let result = SharedCore.TextFormatParserKt.parseTextFormat( + text: text, + ranges: ranges.map { + SharedCore.ProtectedRange( + start: Int32($0.range.location), + end: Int32(NSMaxRange($0.range)), + kind: $0.kind.kotlin + ) + } + ) + return FormattedText( + display: result.display, + spans: result.spans.map(Span.init), + displayRanges: result.displayRanges.map(DisplayRange.init) + ) + } +} + +extension TextFormat.RangeKind { + + var kotlin: SharedCore.RangeKind { + switch self { + case .link: return .link + case .mention: return .mention + } + } + + // Kotlin enums arrive as classes, so the switch cannot be exhaustive. A case added in Kotlin + // without one here is a programming error, and `TextFormatTests` walks `entries` to catch it. + init(_ kind: SharedCore.RangeKind) { + switch kind { + case .link: self = .link + case .mention: self = .mention + default: preconditionFailure("Unmapped RangeKind \(kind.name)") + } + } +} + +extension TextFormat.Span { + + init(_ span: SharedCore.StyledSpan) { + let range = NSRange(location: Int(span.start), length: Int(span.end - span.start)) + switch span.style { + case .bold: self = .bold(range) + case .italic: self = .italic(range) + case .strike: self = .strike(range) + case .code: self = .code(range) + case .codeblock: self = .codeBlock(range) + case .quote: self = .quote(range) + case .bullet: self = .bullet(range) + case .numbered: self = .numbered(range) + default: preconditionFailure("Unmapped FormatStyle \(span.style.name)") + } + } +} + +extension TextFormat.DisplayRange { + + init(_ range: SharedCore.DisplayRange) { + self.init( + range: NSRange(location: Int(range.start), length: Int(range.end - range.start)), + kind: TextFormat.RangeKind(range.kind), + target: range.target.flatMap(URL.init(string:)) + ) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/TextFormatTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/TextFormatTests.swift new file mode 100644 index 0000000000..da658ffa6b --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/TextFormatTests.swift @@ -0,0 +1,50 @@ +import Foundation +import SharedCore +import Testing +@testable import SharedCoreKit + +@Suite struct TextFormatTests { + + @Test func stripsTheMarkersAroundBold() { + let result = TextFormat.parse("*bold*") + #expect(result.display == "bold") + #expect(result.spans == [.bold(NSRange(location: 0, length: 4))]) + #expect(result.displayRanges.isEmpty) + } + + /// Offsets are UTF-16 on both sides; an emoji is two code units and must not shift the span. + @Test func countsOffsetsInUTF16() { + let result = TextFormat.parse("😀 *b*") + #expect(result.display == "😀 b") + #expect(result.spans == [.bold(NSRange(location: 3, length: 1))]) + } + + @Test func turnsAMaskedLinkIntoALinkOverItsText() { + let text = "[docs](https://example.com)" + let url = (text as NSString).range(of: "https://example.com") + let result = TextFormat.parse(text, ranges: [.init(range: url, kind: .link)]) + #expect(result.display == "docs") + #expect(result.displayRanges == [ + .init(range: NSRange(location: 0, length: 4), kind: .link, target: URL(string: "https://example.com")), + ]) + } + + @Test func leavesMarkersInsideAMentionAlone() { + let text = "@a*b*" + let result = TextFormat.parse(text, ranges: [.init(range: NSRange(location: 0, length: 5), kind: .mention)]) + #expect(result.display == text) + #expect(result.spans.isEmpty) + #expect(result.displayRanges == [.init(range: NSRange(location: 0, length: 5), kind: .mention)]) + } + + /// A style or kind added in Kotlin compiles fine on both sides and would trap in the mapping. + @Test func mapsEveryKotlinCase() { + let styles = Set(SharedCore.FormatStyle.entries.map { + TextFormat.Span(SharedCore.StyledSpan(start: 0, end: 1, style: $0)) + }) + #expect(styles.count == SharedCore.FormatStyle.entries.count) + + let kinds = Set(SharedCore.RangeKind.entries.map(TextFormat.RangeKind.init)) + #expect(kinds.count == SharedCore.RangeKind.entries.count) + } +}