From 4db0f438c5a527aa55ba82b54ee3e1aa091792aa Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sun, 17 May 2026 17:49:51 -0700 Subject: [PATCH] Real attributed strings * `runs` * `characters` * `append` * `+` operator * `AttributeContainer` --- README.md | 8 ++ .../SkipFoundation/AttributeContainer.swift | 86 ++++++++++++ Sources/SkipFoundation/AttributedString.swift | 95 +++++++++++--- .../SkipFoundation/AttributedStringKey.swift | 15 +++ .../AttributedStringMarkdown.swift | 123 ++++++++++++++++++ .../AttributedStringMutation.swift | 70 ++++++++++ .../SkipFoundation/AttributedStringRuns.swift | 54 ++++++++ .../SkipFoundation/FoundationAttributes.swift | 74 +++++++++++ .../Formats/TestAttributedStringSkip.swift | 52 ++++++++ 9 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 Sources/SkipFoundation/AttributeContainer.swift create mode 100644 Sources/SkipFoundation/AttributedStringKey.swift create mode 100644 Sources/SkipFoundation/AttributedStringMarkdown.swift create mode 100644 Sources/SkipFoundation/AttributedStringMutation.swift create mode 100644 Sources/SkipFoundation/AttributedStringRuns.swift create mode 100644 Sources/SkipFoundation/FoundationAttributes.swift create mode 100644 Tests/SkipFoundationTests/Formats/TestAttributedStringSkip.swift diff --git a/README.md b/README.md index 3a75589..aec3ac3 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,18 @@ Support levels: AttributedString diff --git a/Sources/SkipFoundation/AttributeContainer.swift b/Sources/SkipFoundation/AttributeContainer.swift new file mode 100644 index 0000000..e359f5a --- /dev/null +++ b/Sources/SkipFoundation/AttributeContainer.swift @@ -0,0 +1,86 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP + +/// A container for attribute keys and values. +public struct AttributeContainer : Hashable { + internal var storage: AttributeStorage + + public init() { + storage = AttributeStorage() + } + + internal init(_ storage: AttributeStorage) { + self.storage = storage + } + + public func value(key: String) -> Any? { + return storage.value(key: key) + } + + public mutating func setValue(_ value: Any?, key: String) { + storage.setValue(value, key: key) + } +} + +/// Type-erased attribute storage keyed by attribute name. +struct AttributeStorage : Hashable { + private var values: [String: Any] = [:] + + func value(key: String) -> Any? { + return values[key] + } + + mutating func setValue(_ value: Any?, key: String) { + if let value { + values[key] = value + } else { + values.removeValue(forKey: key) + } + } + + func merging(_ other: AttributeStorage) -> AttributeStorage { + var result = self + for (key, value) in other.values { + result.values[key] = value + } + return result + } + + static func ==(lhs: AttributeStorage, rhs: AttributeStorage) -> Bool { + guard lhs.values.count == rhs.values.count else { return false } + for (key, lhsValue) in lhs.values { + guard let rhsValue = rhs.values[key] else { return false } + if !AttributeStorage.valuesEqual(lhsValue, rhsValue) { return false } + } + return true + } + + private static func valuesEqual(_ lhs: Any, _ rhs: Any) -> Bool { + if let lhs = lhs as? Bool, let rhs = rhs as? Bool { return lhs == rhs } + if let lhs = lhs as? String, let rhs = rhs as? String { return lhs == rhs } + if let lhs = lhs as? URL, let rhs = rhs as? URL { return lhs == rhs } + return false + } + + func hash(into hasher: inout Hasher) { + for key in values.keys.sorted() { + hasher.combine(key) + hasher.combine(values[key]) + } + } + + func attributeNames() -> [String] { + return Array(values.keys) + } + + func value(forName name: String) -> Any? { + return values[name] + } + + mutating func setValue(name: String, value: Any) { + values[name] = value + } +} + +#endif diff --git a/Sources/SkipFoundation/AttributedString.swift b/Sources/SkipFoundation/AttributedString.swift index 22afe34..67ee333 100644 --- a/Sources/SkipFoundation/AttributedString.swift +++ b/Sources/SkipFoundation/AttributedString.swift @@ -2,49 +2,102 @@ // SPDX-License-Identifier: MPL-2.0 #if SKIP public struct AttributedString: Hashable { - // Allow e.g. SwiftUI to access our state - public let string: String - public let markdownNode: MarkdownNode? + /// An index into an attributed string, measured in UTF-16 code units. + public struct Index : Hashable, Comparable { + internal let utf16Offset: Int + + internal init(utf16Offset: Int) { + self.utf16Offset = utf16Offset + } + + public static func < (lhs: Index, rhs: Index) -> Bool { + return lhs.utf16Offset < rhs.utf16Offset + } + } + + /// A run of characters sharing the same attributes. + public struct Run : Hashable { + public let utf16Range: Range + public let attributes: AttributeContainer + + internal init(utf16Range: Range, attributes: AttributeContainer) { + self.utf16Range = utf16Range + self.attributes = attributes + } + } + + /// The plain text content. + public var characters: String + + internal var _runs: [Run] + + /// Plain text for display and compatibility. + public var string: String { + return characters + } + + internal init(characters: String, runs: [Run]) { + self.characters = characters + self._runs = runs + } public init() { - string = "" - markdownNode = nil + characters = "" + _runs = [] } - public init(stringLiteral: String) { - string = stringLiteral - markdownNode = nil + public init(stringLiteral value: String) { + self.init(value) } public init(markdown: String) throws { - string = markdown - markdownNode = MarkdownNode.from(string: markdown) + if let node = MarkdownNode.from(string: markdown) { + let built = Self.from(markdown: node) + characters = built.characters + _runs = built._runs + } else { + characters = markdown + _runs = Self.singleRun(length: markdown.count, attributes: AttributeContainer()) + } } - public init(localized keyAndValue: String.LocalizationValue, /* options: AttributedString.FormattingOptions = [], */ table: String? = nil, bundle: Bundle? = nil, locale: Locale? = nil, comment: String? = nil) { - let key = keyAndValue.patternFormat // interpolated string: "Hello \(name)" keyed as: "Hello %@" + public init(localized keyAndValue: String.LocalizationValue, table: String? = nil, bundle: Bundle? = nil, locale: Locale? = nil, comment: String? = nil) { + let key = keyAndValue.patternFormat let (_, locfmt, locnode) = (bundle ?? Bundle.main).localizedInfo(forKey: key, value: nil, table: table, locale: locale) - // re-interpret the placeholder strings in the resulting localized string with the string interpolation's values - self.string = locfmt.format(*keyAndValue.stringInterpolation.values.toTypedArray()) - self.markdownNode = locnode?.format(keyAndValue.stringInterpolation.values) + let characters = locfmt.format(*keyAndValue.stringInterpolation.values.toTypedArray()) + if let locnode { + let built = Self.from(markdown: locnode.format(keyAndValue.stringInterpolation.values), interpolations: keyAndValue.stringInterpolation.values) + self.characters = characters + _runs = built._runs + } else { + self.characters = characters + _runs = Self.singleRun(length: characters.count, attributes: AttributeContainer()) + } } public init(localized key: String, table: String? = nil, bundle: Bundle? = nil, locale: Locale? = nil, comment: String? = nil) { let (locstring, _, locnode) = (bundle ?? Bundle.main).localizedInfo(forKey: key, value: nil, table: table, locale: locale) - self.string = locstring - self.markdownNode = locnode + if let locnode { + let built = Self.from(markdown: locnode) + characters = locstring + _runs = built._runs + } else { + characters = locstring + _runs = Self.singleRun(length: locstring.count, attributes: AttributeContainer()) + } } public var description: String { - return string + return characters } - public static func ==(lhs: AttributedString, rhs: AttributedString) { - return lhs.string == rhs.string + public static func ==(lhs: AttributedString, rhs: AttributedString) -> Bool { + return lhs.characters == rhs.characters && lhs._runs == rhs._runs } public func hash(into hasher: inout Hasher) { - hasher.combine(string) + hasher.combine(characters) + hasher.combine(_runs) } } diff --git a/Sources/SkipFoundation/AttributedStringKey.swift b/Sources/SkipFoundation/AttributedStringKey.swift new file mode 100644 index 0000000..a443a2e --- /dev/null +++ b/Sources/SkipFoundation/AttributedStringKey.swift @@ -0,0 +1,15 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP + +/// A type that defines an attributed string attribute's name and value type. +public protocol AttributedStringKey { + associatedtype Value : Hashable + static var name: String { get } +} + +/// A type that collects related attribute keys. +public protocol AttributeScope { +} + +#endif diff --git a/Sources/SkipFoundation/AttributedStringMarkdown.swift b/Sources/SkipFoundation/AttributedStringMarkdown.swift new file mode 100644 index 0000000..323d688 --- /dev/null +++ b/Sources/SkipFoundation/AttributedStringMarkdown.swift @@ -0,0 +1,123 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP + +extension AttributedString { + /// Builds an attributed string from a markdown AST node. + internal static func from(markdown: MarkdownNode, interpolations: kotlin.collections.List? = nil) -> AttributedString { + var builder = MarkdownRunBuilder() + appendMarkdown(markdown, to: &builder, interpolations: interpolations, isFirstChild: true) + return AttributedString(characters: builder.text, runs: builder.runs()) + } + + private static func appendMarkdown(_ markdown: MarkdownNode, to builder: inout MarkdownRunBuilder, interpolations: kotlin.collections.List?, isFirstChild: Bool) { + func appendChildren() { + markdown.children?.forEachIndexed { appendMarkdown($1, to: &builder, interpolations: interpolations, isFirstChild: $0 == 0) } + } + + switch markdown.type { + case MarkdownNode.NodeType.bold: + builder.pushBold() + appendChildren() + builder.pop() + case MarkdownNode.NodeType.code: + builder.pushCode() + if let text = markdown.formattedString(interpolations) { + builder.append(text) + } + builder.pop() + case MarkdownNode.NodeType.italic: + builder.pushItalic() + appendChildren() + builder.pop() + case MarkdownNode.NodeType.link: + if let urlString = markdown.formattedString(interpolations), let url = URL(string: urlString) { + builder.pushLink(url) + } + appendChildren() + builder.pop() + case MarkdownNode.NodeType.paragraph: + if !isFirstChild { + builder.append("\n\n") + } + appendChildren() + case MarkdownNode.NodeType.root: + appendChildren() + case MarkdownNode.NodeType.strikethrough: + builder.pushStrikethrough() + appendChildren() + builder.pop() + case MarkdownNode.NodeType.text: + if let text = markdown.formattedString(interpolations) { + builder.append(text) + } + case MarkdownNode.NodeType.unknown: + appendChildren() + } + } +} + +/// Walks markdown and produces runs with varying attributes per text segment. +struct MarkdownRunBuilder { + private var attributeStack: [AttributeContainer] = [AttributeContainer()] + private var segments: [(String, AttributeContainer)] = [] + + mutating func pushBold() { + push { $0.markdownBold = true } + } + + mutating func pushItalic() { + push { $0.markdownItalic = true } + } + + mutating func pushStrikethrough() { + push { $0.markdownStrikethrough = true } + } + + mutating func pushCode() { + push { $0.markdownCode = true } + } + + mutating func pushLink(_ url: URL) { + push { $0.link = url } + } + + private mutating func push(_ update: (inout AttributeContainer) -> Void) { + var container = attributeStack.last ?? AttributeContainer() + update(&container) + attributeStack.append(container) + } + + mutating func pop() { + if attributeStack.count > 1 { + attributeStack.removeLast() + } + } + + mutating func append(_ string: String) { + guard !string.isEmpty else { return } + segments.append((string, attributeStack.last ?? AttributeContainer())) + } + + var text: String { + return segments.map { $0.0 }.joined() + } + + func runs() -> [AttributedString.Run] { + var result: [AttributedString.Run] = [] + var offset = 0 + for (segment, attributes) in segments { + let length = segment.count + if length > 0 { + result.append(AttributedString.Run( + utf16Range: offset..<(offset + length), + attributes: attributes + )) + offset += length + } + } + return AttributedString.coalesce(result) + } +} + +#endif diff --git a/Sources/SkipFoundation/AttributedStringMutation.swift b/Sources/SkipFoundation/AttributedStringMutation.swift new file mode 100644 index 0000000..a6127ea --- /dev/null +++ b/Sources/SkipFoundation/AttributedStringMutation.swift @@ -0,0 +1,70 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP + +extension AttributedString { + public init(_ string: String) { + self.init(characters: string, runs: Self.singleRun(length: string.count, attributes: AttributeContainer())) + } + + public init(_ string: String, attributes: AttributeContainer) { + self.init(characters: string, runs: Self.singleRun(length: string.count, attributes: attributes)) + } + + public func attributeValue(key: String) -> Any? { + guard _runs.count == 1 else { + return attributes(in: entireUTF16Range).value(key: key) + } + return _runs[0].attributes.value(key: key) + } + + public mutating func setAttributeValue(_ value: Any?, key: String) { + if _runs.isEmpty && !characters.isEmpty { + _runs = Self.singleRun(length: characters.count, attributes: AttributeContainer()) + } + _runs = _runs.map { run in + var attrs = run.attributes + attrs.setValue(value, key: key) + return Run(utf16Range: run.utf16Range, attributes: attrs) + } + } + + // SKIP DECLARE: operator fun plus(other: AttributedString): AttributedString + public func plus(other: AttributedString) -> AttributedString { + var result = self + result.append(other) + return result + } + + // SKIP DECLARE: operator fun plusAssign(other: AttributedString) + public mutating func plusAssign(other: AttributedString) { + append(other) + } + + public mutating func append(_ other: AttributedString) { + let offset = _utf16Length + characters += other.characters + for run in other._runs { + let runUTF16 = run.utf16Range + let shifted = Run( + utf16Range: (runUTF16.lowerBound + offset)..<(runUTF16.upperBound + offset), + attributes: run.attributes + ) + _runs.append(shifted) + } + _runs = Self.coalesce(_runs) + } + + internal func attributes(in utf16Range: Range) -> AttributeContainer { + var merged = AttributeContainer() + for run in _runs { + let runUTF16 = run.utf16Range + if runUTF16.lowerBound < utf16Range.upperBound && runUTF16.upperBound > utf16Range.lowerBound { + merged.storage = merged.storage.merging(run.attributes.storage) + } + } + return merged + } +} + +#endif diff --git a/Sources/SkipFoundation/AttributedStringRuns.swift b/Sources/SkipFoundation/AttributedStringRuns.swift new file mode 100644 index 0000000..ceed5eb --- /dev/null +++ b/Sources/SkipFoundation/AttributedStringRuns.swift @@ -0,0 +1,54 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP + +extension AttributedString { + public var entireUTF16Range: Range { + return 0..<_utf16Length + } + + /// Attribute runs in the attributed string. + public var runs: [Run] { + return _runs + } + + public var startIndex: Index { + return Index(utf16Offset: 0) + } + + public var endIndex: Index { + return Index(utf16Offset: _utf16Length) + } + + internal var _utf16Length: Int { + return characters.count + } + + public func substring(in range: Range) -> String { + return String(characters[range.lowerBound.. Index { + return Index(utf16Offset: utf16Offset) + } + + internal static func coalesce(_ runs: [Run]) -> [Run] { + guard !runs.isEmpty else { return [] } + var result: [Run] = [] + for run in runs { + if let last = result.last, last.attributes == run.attributes, last.utf16Range.upperBound == run.utf16Range.lowerBound { + result[result.count - 1] = Run(utf16Range: last.utf16Range.lowerBound.. [Run] { + guard length > 0 else { return [] } + return [Run(utf16Range: 0..