Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,18 @@ Support levels:
<summary><code>AttributedString</code></summary>
<ul>
<li><code>init()</code></li>
<li><code>init(_ string: String)</code></li>
<li><code>init(_ string: String, attributes: AttributeContainer)</code></li>
<li><code>init(stringLiteral: String)</code></li>
<li><code>init(markdown: String)</code></li>
<li><code>init(localized keyAndValue: String.LocalizationValue, table: String? = nil, bundle: Bundle? = nil, locale: Locale? = nil, comment: String? = nil)</code></li>
<li><code>init(localized key: String, table: String? = nil, bundle: Bundle? = nil, locale: Locale = Locale.current, comment: String? = nil)</code></li>
<li><code>var characters: String</code></li>
<li><code>var string: String</code></li>
<li><code>var runs: Runs</code></li>
<li><code>func append(_ other: AttributedString)</code></li>
<li><code>static func +(lhs:rhs:)</code> (Kotlin <code>plus</code> operator)</li>
<li><code>AttributeContainer</code>, <code>AttributedStringKey</code>, <code>AttributeScopes.FoundationAttributes</code> (link, markdown)</li>
</ul>
</details>
</td>
Expand Down
86 changes: 86 additions & 0 deletions Sources/SkipFoundation/AttributeContainer.swift
Original file line number Diff line number Diff line change
@@ -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
95 changes: 74 additions & 21 deletions Sources/SkipFoundation/AttributedString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>
public let attributes: AttributeContainer

internal init(utf16Range: Range<Int>, 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)
}
}

Expand Down
15 changes: 15 additions & 0 deletions Sources/SkipFoundation/AttributedStringKey.swift
Original file line number Diff line number Diff line change
@@ -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
123 changes: 123 additions & 0 deletions Sources/SkipFoundation/AttributedStringMarkdown.swift
Original file line number Diff line number Diff line change
@@ -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<AnyHashable>? = 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<AnyHashable>?, 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
Loading