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
init()
+init(_ string: String)
+init(_ string: String, attributes: AttributeContainer)
init(stringLiteral: String)
init(markdown: String)
init(localized keyAndValue: String.LocalizationValue, table: String? = nil, bundle: Bundle? = nil, locale: Locale? = nil, comment: String? = nil)
init(localized key: String, table: String? = nil, bundle: Bundle? = nil, locale: Locale = Locale.current, comment: String? = nil)
+var characters: String
+var string: String
+var runs: Runs
+func append(_ other: AttributedString)
+static func +(lhs:rhs:) (Kotlin plus operator)
+AttributeContainer, AttributedStringKey, AttributeScopes.FoundationAttributes (link, markdown)
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..