From 760a19c9953dd62b435ac31a179c030f13b8ed7c Mon Sep 17 00:00:00 2001 From: Oliver Atkinson Date: Wed, 3 Jun 2026 10:10:39 +0100 Subject: [PATCH] Add event details and subscriber APIs --- Package.swift | 3 +- Sources/Lexicon/CLI.Error.swift | 8 +- Sources/Lexicon/CLI.Session.swift | 7 +- Sources/Lexicon/Lexicon.Document.Merge.swift | 6 +- Sources/Lexicon/Lexicon.Document.Search.swift | 2 +- Sources/Lexicon/Lexicon.Document.swift | 2 +- Sources/Lexicon/TaskPaper.swift | 92 +++-- Sources/Lexicon/util/Optional.swift | 2 +- Sources/Lexicon/util/String.swift | 33 +- Sources/SwiftLexicon/Event.swift | 11 +- .../EventSubscriptionSetBuilder.swift | 44 +- Sources/SwiftLexicon/Events.swift | 108 ++++- Sources/SwiftLexicon/K.swift | 246 ++++++++++-- Sources/SwiftLexicon/SwiftUI.swift | 123 ++++-- Sources/lexicon-generate/CodeGenerator.swift | 2 +- .../GoGeneratorTests.swift | 6 +- .../RustGeneratorTests.swift | 4 +- .../LexiconLSPCommandTests.swift | 7 +- "Tests/LexiconTests/CLI\342\204\242.swift" | 204 +++++----- .../LexiconTests/LexiconCLICommandTests.swift | 3 +- Tests/LexiconTests/READMEExampleTests.swift | 2 +- Tests/LexiconTests/StringSortingTests.swift | 33 ++ .../LexiconTests/TaskPaper\342\204\242.swift" | 67 +++- Tests/LexiconTests/util/String.swift | 2 +- .../EventSubscriberTests.swift | 378 ++++++++++++++++++ .../EventSubscriptionSetBuilderTests.swift | 40 ++ Tests/SwiftLexiconTests/KBracketedTests.swift | 96 +++++ .../SwiftLexicon\342\204\242.swift" | 2 +- .../SwiftUISupportTests.swift | 39 ++ Tests/SwiftLexiconTests/util.swift | 3 +- 30 files changed, 1319 insertions(+), 256 deletions(-) create mode 100644 Tests/LexiconTests/StringSortingTests.swift create mode 100644 Tests/SwiftLexiconTests/EventSubscriberTests.swift create mode 100644 Tests/SwiftLexiconTests/EventSubscriptionSetBuilderTests.swift create mode 100644 Tests/SwiftLexiconTests/KBracketedTests.swift create mode 100644 Tests/SwiftLexiconTests/SwiftUISupportTests.swift diff --git a/Package.swift b/Package.swift index 02ef53f..9df577b 100644 --- a/Package.swift +++ b/Package.swift @@ -56,7 +56,8 @@ let package = Package( .testTarget( name: "LexiconTests", dependencies: [ - "Lexicon" + "Lexicon", + "SwiftLexicon" ], resources: [.copy("Resources")] ), diff --git a/Sources/Lexicon/CLI.Error.swift b/Sources/Lexicon/CLI.Error.swift index 0df1e15..9f60dad 100644 --- a/Sources/Lexicon/CLI.Error.swift +++ b/Sources/Lexicon/CLI.Error.swift @@ -29,24 +29,24 @@ public extension CLI.Error { case "invalidInputCharacter": guard let string = json.string, string.count == 1 else { - throw "CLI.Error.invalidInputCharacter missing character in \(json)" + throw LexiconError("CLI.Error.invalidInputCharacter missing character in \(json)") } self = .invalidInputCharacter(string.first!) case "noChildrenMatchInput": guard let string = json.string else { - throw "CLI.Error.noChildrenMatchInput missing string in \(json)" + throw LexiconError("CLI.Error.noChildrenMatchInput missing string in \(json)") } self = .noChildrenMatchInput(string) case "invalidSelection": guard let int = json.int else { - throw "CLI.Error.invalidSelection missing int in \(json)" + throw LexiconError("CLI.Error.invalidSelection missing int in \(json)") } self = .invalidSelection(index: int) default: - throw "CLI.Error cannot decode \(json)" + throw LexiconError("CLI.Error cannot decode \(json)") } } diff --git a/Sources/Lexicon/CLI.Session.swift b/Sources/Lexicon/CLI.Session.swift index fbecd4f..a20ff4e 100644 --- a/Sources/Lexicon/CLI.Session.swift +++ b/Sources/Lexicon/CLI.Session.swift @@ -17,12 +17,15 @@ public extension CLI { self.events = [] } - // TODO: revisit with composable lexicons public func event(cli: CLI, event: CustomDebugStringConvertible) async -> Event { + await self.event(cli: cli, description: event.debugDescription) + } + + public func event(cli: CLI, description: String) async -> Event { Event( time: Date.timeIntervalSinceReferenceDate - startTime, record: await cli.record(), - description: event.debugDescription + description: description ) } } diff --git a/Sources/Lexicon/Lexicon.Document.Merge.swift b/Sources/Lexicon/Lexicon.Document.Merge.swift index 9eb3ebb..5b2d2ff 100644 --- a/Sources/Lexicon/Lexicon.Document.Merge.swift +++ b/Sources/Lexicon/Lexicon.Document.Merge.swift @@ -245,7 +245,7 @@ private extension Lexicon.Document { } let components = anchorID.components(separatedBy: ".").filter { !$0.isEmpty } guard components.first == rootName, let anchorName = components.last else { - throw "Cannot graft imported lexicon at '\(anchorID)' inside root '\(rootName)'" + throw LexiconError("Cannot graft imported lexicon at '\(anchorID)' inside root '\(rootName)'") } let parentPath = components.dropLast().unlessEmpty?.joined(separator: ".") let leaf = sourceRoot.rebased( @@ -314,7 +314,7 @@ private extension Lexicon.Graph.Node { return } guard var child = children[name] else { - throw "Could not find lemma path component: \(name)" + throw LexiconError("Could not find lemma path component: \(name)") } try child.mutate(path: path.dropFirst(), body: body) children[name] = child @@ -325,7 +325,7 @@ private extension SortedDictionary where Key == String, Value == Lexicon.Graph.N mutating func mutate(_ key: Key, body: (inout Value) throws -> Void) throws { guard var value = self[key] else { - throw "Could not find lemma: \(key)" + throw LexiconError("Could not find lemma: \(key)") } try body(&value) self[key] = value diff --git a/Sources/Lexicon/Lexicon.Document.Search.swift b/Sources/Lexicon/Lexicon.Document.Search.swift index 6dc5533..277ab70 100644 --- a/Sources/Lexicon/Lexicon.Document.Search.swift +++ b/Sources/Lexicon/Lexicon.Document.Search.swift @@ -622,7 +622,7 @@ public extension Lexicon.Search { ) async throws -> [[Double]] { let embeddings = try await provider.embed(texts) guard embeddings.count == texts.count else { - throw "Embedding provider returned \(embeddings.count) vectors for \(texts.count) texts." + throw LexiconError("Embedding provider returned \(embeddings.count) vectors for \(texts.count) texts.") } return embeddings } diff --git a/Sources/Lexicon/Lexicon.Document.swift b/Sources/Lexicon/Lexicon.Document.swift index 4b685bb..6f70cfe 100644 --- a/Sources/Lexicon/Lexicon.Document.swift +++ b/Sources/Lexicon/Lexicon.Document.swift @@ -50,7 +50,7 @@ public extension Lexicon { node = root } guard let root = node else { - throw "The document does not declare a root lemma" + throw LexiconError("The document does not declare a root lemma") } return Graph(root: root, date: date) } diff --git a/Sources/Lexicon/TaskPaper.swift b/Sources/Lexicon/TaskPaper.swift index 957fe00..2761b25 100644 --- a/Sources/Lexicon/TaskPaper.swift +++ b/Sources/Lexicon/TaskPaper.swift @@ -13,33 +13,48 @@ public extension UTType { } public class TaskPaper { - + public typealias Node = Lexicon.Graph.Node - + + public struct Options: Sendable, Hashable { + public var allowsPlainTextOutlines: Bool + + public init(allowsPlainTextOutlines: Bool = false) { + self.allowsPlainTextOutlines = allowsPlainTextOutlines + } + + public static let lexicon = Self() + public static let plainTextOutline = Self(allowsPlainTextOutlines: true) + } + public static let pattern = try! ( line: NSRegularExpression(pattern: "^(?\\t*)(?.+)"), - lemma: NSRegularExpression(pattern: "^(?[\\w]+):?\\s*$"), // TODO: Optional colon `:?` allows plain text (tabbed) outlines, but this should be opted into + lemma: NSRegularExpression(pattern: "^(?[\\w]+):\\s*$"), + plainTextLemma: NSRegularExpression(pattern: "^(?[\\w]+):?\\s*$"), operator: NSRegularExpression(pattern: "^(?[+=?])\\s*(?.*\\S)?\\s*$") ) - + public let string: String - + public let options: Options + private var result: Result? private var documentResult: Result? - - public init(_ string: String) { + + public init(_ string: String, options: Options = .lexicon) { self.string = string + self.options = options } - - public init(_ UTF8: Data) throws { + + public init(_ UTF8: Data, options: Options = .lexicon) throws { guard let string = String(data: UTF8, encoding: .utf8) else { - throw "Data is not UTF-8" + throw LexiconError("Data is not UTF-8") } self.string = string + self.options = options } - + public func decode() throws -> Lexicon.Graph { - + if let result = result { return try result.get() } @@ -63,7 +78,7 @@ public class TaskPaper { var path: [Node] = [] var document = Lexicon.Document() var error: Error? - + string.enumerateLines{ line, stop in do { guard let match = TaskPaper.pattern.line.firstMatch(in: line, options: [], range: line.nsRange) else { @@ -78,7 +93,7 @@ public class TaskPaper { error = o } } - + while path.count > 1 { reduce(&path) } @@ -90,7 +105,7 @@ public class TaskPaper { documentResult = .failure(error) throw error } - + documentResult = .success(document) return document } @@ -115,7 +130,7 @@ public class TaskPaper { continue } try decode(line: sourceLine.content, depth: sourceLine.depth, path: &path, document: &document) - if let line = SourceMap.Line(sourceLine, path: path) { + if let line = SourceMap.Line(sourceLine, path: path, taskPaper: self) { lines.append(line) } } @@ -127,7 +142,7 @@ public class TaskPaper { let child = path.removeLast() path[path.endIndex - 1].children[child.name] = child } - + func decode(line: String, depth: Int, path: inout [Node], document: inout Lexicon.Document) throws { if line.hasPrefix("@ ") { @@ -162,9 +177,9 @@ public class TaskPaper { } return } - - let name = TaskPaper.pattern.lemma.first(in: line)?["lemma"] - + + let name = lemmaName(in: line) + guard path.isNotEmpty else { guard let name = name, depth == 0 else { return // ignore everything before the first root node @@ -172,7 +187,7 @@ public class TaskPaper { path = [Node(name: name)] return } - + if let name = name { if depth == 0 { @@ -180,11 +195,11 @@ public class TaskPaper { path = [Node(name: name)] return } - + let indent = depth - (path.count - 1) - + switch indent { - + case 1: // child break @@ -193,19 +208,19 @@ public class TaskPaper { case ..<0: // ancestor guard path.count + indent > 0 else { - throw "Line with wrong indent (\(indent)): '\(line)'" + throw LexiconError("Line with wrong indent (\(indent)): '\(line)'") } for _ in 0...abs(indent) { reduce(&path) } - + default: - throw "Line with wrong indent (\(indent)): '\(line)'" + throw LexiconError("Line with wrong indent (\(indent)): '\(line)'") } - + path.append(Node(name: name)) } - + else if let match = TaskPaper.pattern.operator.first(in: line), let symbol = match["operator"], @@ -243,6 +258,11 @@ public class TaskPaper { reduce(&path) } } + + func lemmaName(in line: String) -> String? { + let pattern = options.allowsPlainTextOutlines ? Self.pattern.plainTextLemma : Self.pattern.lemma + return pattern.first(in: line)?["lemma"] + } } public extension TaskPaper { @@ -300,15 +320,15 @@ public extension TaskPaper { static func encode(_ node: Lexicon.Graph.Node, date: Date = .init()) -> String { encode(Lexicon.Graph(root: node, date: date)) } - + static func encode(_ graph: Lexicon.Graph) -> String { encode(Lexicon.Document(graph)) } static func encode(_ document: Lexicon.Document) -> String { - + var lines: [String] = [] - + for comment in document.comments { lines.append("# \(comment)") } @@ -339,13 +359,13 @@ public extension TaskPaper { if let protonym = node.protonym { lines.append("\(tabs)= \(protonym)") } else { - for type in node.type.sorted(by: <) { // TODO: consider whether to sort it lexicographically + for type in node.type.sorted(by: <) { lines.append("\(tabs)+ \(type)") } } } } - + return lines.joined(separator: "\n") } } @@ -433,9 +453,9 @@ private extension TaskPaper { private extension TaskPaper.SourceMap.Line { - init?(_ sourceLine: TaskPaper.ParsedSourceLine, path: [TaskPaper.Node]) { + init?(_ sourceLine: TaskPaper.ParsedSourceLine, path: [TaskPaper.Node], taskPaper: TaskPaper) { let nodePath = TaskPaper.nodePath(path) - if let name = TaskPaper.pattern.lemma.first(in: sourceLine.content)?["lemma"] { + if let name = taskPaper.lemmaName(in: sourceLine.content) { self.init( line: sourceLine.line, depth: sourceLine.depth, diff --git a/Sources/Lexicon/util/Optional.swift b/Sources/Lexicon/util/Optional.swift index 9fbdc1f..e2d21ed 100644 --- a/Sources/Lexicon/util/Optional.swift +++ b/Sources/Lexicon/util/Optional.swift @@ -88,7 +88,7 @@ public extension Optional { _ file: String = #file, _ line: Int = #line ) throws -> Wrapped { - try or(throw: "⚠️ \(function):\(file):\(line)") + try or(throw: LexiconError("Missing value in \(function):\(file):\(line)")) } @inlinable func or(throw error: @autoclosure () -> Error) throws -> Wrapped { diff --git a/Sources/Lexicon/util/String.swift b/Sources/Lexicon/util/String.swift index e658c5d..727b864 100644 --- a/Sources/Lexicon/util/String.swift +++ b/Sources/Lexicon/util/String.swift @@ -4,16 +4,17 @@ import Foundation -// TODO: sort, document & refactor! +public struct LexiconError: Error, Equatable, Sendable, CustomStringConvertible, LocalizedError { + public var description: String + public var errorDescription: String? { description } -#if compiler(>=6.0) -extension String: @retroactive Error {} // TODO: dedicated error types -#else -extension String: Error {} // TODO: dedicated error types -#endif + public init(_ description: String) { + self.description = description + } +} extension String { - + func dotPath(after: String) -> String { guard hasPrefix(after) else { return self @@ -30,20 +31,20 @@ extension String { } extension String { - + var nsRange: NSRange { return .init(location: 0, length: utf16.count) } - + func substring(with range: NSRange) -> String { return String(NSString(string: self).substring(with: range)) } - + static func * (lhs: String, rhs: Int) -> String { return repeatElement(lhs, count: rhs).joined() } } extension String.Indices { - + var range: Range { indices.startIndex ..< indices.endIndex } @@ -59,7 +60,7 @@ struct StringMatch { } extension StringMatch { - + subscript(_ name: String) -> String? { guard let range = result?.range(withName: name), @@ -73,7 +74,7 @@ extension StringMatch { } extension NSRegularExpression { - + func first(in string: String, options: NSRegularExpression.MatchingOptions = []) -> StringMatch? { guard let match = firstMatch(in: string, options: options, range: string.nsRange) else { return nil @@ -83,9 +84,7 @@ extension NSRegularExpression { } public extension Sequence { - - // TODO: use SortComparator-s after dropping support for iOS 14 and macOS 11 - + @inlinable func sortedByLocalizedStandard(by keyPath: KeyPath, _ order: ComparisonResult = .orderedAscending) -> [Element] { sorted { l, r in l[keyPath: keyPath].localizedStandardCompare(r[keyPath: keyPath]) == order @@ -94,7 +93,7 @@ public extension Sequence { } extension Sequence where Element: StringProtocol { - + @inlinable func sortedByLocalizedStandard(_ order: ComparisonResult = .orderedAscending) -> [Element] { sorted { l, r in l.localizedStandardCompare(r) == order diff --git a/Sources/SwiftLexicon/Event.swift b/Sources/SwiftLexicon/Event.swift index 7d5e34c..7355a0f 100644 --- a/Sources/SwiftLexicon/Event.swift +++ b/Sources/SwiftLexicon/Event.swift @@ -40,6 +40,10 @@ public struct Event: Sendable, Hashable, Identifiable, CustomStringConvertible { Snapshot(id: id, description: description, lemma: l.__, values: values) } + public var detail: EventDetail { + EventDetail(id: l.__, data: values) + } + public func `is`(_ i: I) -> Bool { switch i { case let i as L: @@ -100,11 +104,8 @@ public extension JSON { if let string { return string } - if let array { - return "[" + array.map(\.eventDescription).joined(separator: ", ") + "]" - } - if let object { - return "{" + object.sortedEntries.map { "\($0.key): \($0.value.eventDescription)" }.joined(separator: ", ") + "}" + if array != nil || object != nil, let data = try? data(options: [.fragmentsAllowed, .sortedKeys]) { + return String(decoding: data, as: UTF8.self) } return String(describing: any) } diff --git a/Sources/SwiftLexicon/EventSubscriptionSetBuilder.swift b/Sources/SwiftLexicon/EventSubscriptionSetBuilder.swift index bb004d6..5bc1918 100644 --- a/Sources/SwiftLexicon/EventSubscriptionSetBuilder.swift +++ b/Sources/SwiftLexicon/EventSubscriptionSetBuilder.swift @@ -8,35 +8,61 @@ public typealias Mind = Set @resultBuilder public enum EventSubscriptionSetBuilder {} public extension EventSubscriptionSetBuilder { - + typealias Element = EventSubscription typealias Component = Set - + static func buildBlock(_ components: Element...) -> Component { components.reduce(into: [], +=) } - + static func buildBlock(_ first: Component, _ rest: Component...) -> Component { ([first] + rest).reduce(into: [], +=) } - - // TODO: ... + + static func buildExpression(_ expression: Element) -> Component { + [expression] + } + + static func buildExpression(_ expression: Component) -> Component { + expression + } + + static func buildOptional(_ component: Component?) -> Component { + component ?? [] + } + + static func buildEither(first component: Component) -> Component { + component + } + + static func buildEither(second component: Component) -> Component { + component + } + + static func buildArray(_ components: [Component]) -> Component { + components.reduce(into: [], +=) + } + + static func buildLimitedAvailability(_ component: Component) -> Component { + component + } } public extension Set where Element == EventSubscription { - + @inlinable static func += (lhs: inout Self, rhs: A) where A.Element == EventSubscription { lhs.formUnion(rhs) } - + @inlinable static func += (lhs: inout Self, rhs: EventSubscription) { lhs.insert(rhs) } - + @inlinable mutating func `in`(_ mind: EventSubscription) { insert(mind) } - + @inlinable mutating func `in`(_ mind: A) where A.Element == EventSubscription { formUnion(mind) } diff --git a/Sources/SwiftLexicon/Events.swift b/Sources/SwiftLexicon/Events.swift index 1ac9a0f..a3c77b9 100644 --- a/Sources/SwiftLexicon/Events.swift +++ b/Sources/SwiftLexicon/Events.swift @@ -5,7 +5,7 @@ import Foundation import Synchronization -public final class Events: Sendable { +public final class Events: Identifiable, Sendable { private struct State: Sendable { var nextID: UInt64 = 0 @@ -17,6 +17,10 @@ public final class Events: Sendable { public init() {} + public var id: ObjectIdentifier { + ObjectIdentifier(self) + } + public var stream: AsyncStream { makeStream().stream } @@ -121,9 +125,101 @@ public struct EventHandler: Sendable { } } +public extension Events { + + final class Subscriber: Sendable { + + private struct State: Sendable { + var eventsID: ObjectIdentifier? + var subscription: EventSubscription? + var predicate: @Sendable (Event) -> Bool = { _ in true } + var action: @Sendable (Event) async -> Void = { _ in } + } + + private let state = Mutex(State()) + + public init() {} + + public convenience init( + where predicate: @escaping @Sendable (Event) -> Bool = { _ in true }, + perform action: @escaping @Sendable (Event) async -> Void + ) { + self.init() + update(where: predicate, perform: action) + } + + deinit { + cancel() + } + + public var isSubscribed: Bool { + state.withLock { state in + state.subscription != nil + } + } + + public func update( + where predicate: @escaping @Sendable (Event) -> Bool = { _ in true }, + perform action: @escaping @Sendable (Event) async -> Void + ) { + state.withLock { state in + state.predicate = predicate + state.action = action + } + } + + public func subscribe( + to events: Events?, + where predicate: @escaping @Sendable (Event) -> Bool = { _ in true }, + perform action: @escaping @Sendable (Event) async -> Void + ) { + update(where: predicate, perform: action) + subscribe(to: events) + } + + public func subscribe(to events: Events?) { + state.withLock { state in + let eventsID = events?.id + guard state.eventsID != eventsID || (events != nil && state.subscription == nil) else { + return + } + + state.subscription?.cancel() + state.eventsID = eventsID + state.subscription = events.map { events in + events.subscribe { [weak self] event in + await self?.receive(event) + } + } + } + } + + public func cancel() { + state.withLock { state in + state.subscription?.cancel() + state.eventsID = nil + state.subscription = nil + } + } + + private func receive(_ event: Event) async { + let handler = state.withLock { state in + (predicate: state.predicate, action: state.action) + } + + guard handler.predicate(event) else { + return + } + + await handler.action(event) + } + } +} + public final class EventSubscription: Hashable, Sendable { private let task: Task private let onCancel: @Sendable () -> Void + private let isCancelled = Mutex(false) public init(_ task: Task, onCancel: @escaping @Sendable () -> Void = {}) { self.task = task @@ -135,6 +231,16 @@ public final class EventSubscription: Hashable, Sendable { } public func cancel() { + let shouldCancel = isCancelled.withLock { isCancelled in + guard !isCancelled else { + return false + } + isCancelled = true + return true + } + guard shouldCancel else { + return + } onCancel() task.cancel() } diff --git a/Sources/SwiftLexicon/K.swift b/Sources/SwiftLexicon/K.swift index d9e17b4..098cdfb 100644 --- a/Sources/SwiftLexicon/K.swift +++ b/Sources/SwiftLexicon/K.swift @@ -6,61 +6,197 @@ import Foundation import Lexicon public extension I where Self: L { - + subscript(value: Value) -> K where Value: Sendable, Value: Hashable, Value: Codable { K(self, [self: eventValue(value)]) } } -@dynamicMemberLookup public struct K: Hashable, KProtocol { - +@dynamicMemberLookup public struct K: Hashable, KProtocol, CustomStringConvertible { + public let __: String public let ___: A public let ____: [L: Event.Value] - + public init(_ a: A) { self.init(a, [:]) } - + internal init(_ l: A, _ d: [L: Event.Value]) { self.____ = d self.___ = l - self.__ = d.sorted(by: { $0.key.__.count > $1.key.__.count }).reduce(into: l.__) { (o, e) in - assert(o.starts(with: e.key.__)) - let i = o.index(o.startIndex, offsetBy: e.key.__.count) - o.insert(contentsOf: "[\(e.value.eventDescription)]", at: i) // TODO: measure performance - } + self.__ = bracketedDescription(id: l.__, data: d) } } +private func bracketedDescription(id: String, data: [L: Event.Value]) -> String { + var output = "" + var index = id.startIndex + for entry in data.sorted(by: { $0.key.__.count < $1.key.__.count }) { + assert(id.starts(with: entry.key.__)) + let valueEnd = id.index(id.startIndex, offsetBy: entry.key.__.count) + output += id[index..(dynamicMember keyPath: KeyPath) -> K { K(___[keyPath: keyPath], ____) } - + subscript(value: Value) -> K where Value: Sendable, Value: Hashable, Value: Codable { K(___, ____.merging([___: eventValue(value)], uniquingKeysWith: { _, last in last })) } } public extension K { - + subscript() -> Any? { self[___] } - + subscript(key: L) -> Any? { ____[key]?.base } - + subscript( as type: Value.Type = Value.self, using decoder: JSONDecoder = JSONDecoder() ) -> Value where Value: Decodable { get throws { try self[___, as: Value.self, using: decoder] } } - + subscript( key: L, as type: Value.Type = Value.self, @@ -81,7 +217,7 @@ public protocol KProtocol: I { } public extension K { - + @inlinable func callAsFunction(_: KeyPath) -> L { ___ } @@ -94,22 +230,6 @@ public extension CallAsFunctionKExtensions { struct GetL {} } -extension K { // TODO: ↓ - // private static let brackets = CharacterSet(charactersIn: "[]") - // - // var detail: (id: String, data: [String: String]) { - // let substrings = description.components(separatedBy: Self.brackets).filter(\.isEmpty.not) - // var id = "" - // var data: [String: String] = [:] - // for (i, (k, v)) in zip(substrings, substrings.dropFirst()).enumerated() where i.isMultiple(of: 2) { - // id = (i == 0) ? k : (id + k) - // data[id] = v - // } - // id = substrings.enumerated().filter{ $0.offset.isMultiple(of: 2) }.map(\.element).joined() - // return (id, data) - // } -} - private func eventValue(_ value: Value) -> Event.Value where Value: Encodable { do { return try Event.Value.encoded(value) @@ -117,3 +237,63 @@ private func eventValue(_ value: Value) -> Event.Value where Value: Encod preconditionFailure("Could not encode \(Value.self) as an event JSON value: \(error)") } } + +private extension Event.Value { + + var bracketedDescription: String { + guard let string else { + return eventDescription + } + guard string.isSafeUnquotedEventDescriptionString else { + return jsonEncodedDescription + } + return string + } + + var jsonEncodedDescription: String { + (try? data(options: [.fragmentsAllowed, .sortedKeys])) + .map { String(decoding: $0, as: UTF8.self) } + ?? eventDescription + } + + init(eventDescription: some StringProtocol) { + let description = String(eventDescription) + switch description { + case "null": + self = .null + case "true": + self = .bool(true) + case "false": + self = .bool(false) + default: + if let int = Int(description) { + self = .int(int) + } else if let double = Double(description), description.contains(".") { + self = .double(double) + } else if let value = try? Event.Value(data: Data(description.utf8)) { + self = value + } else { + self = .string(description) + } + } + } +} + +private extension String { + + var isSafeUnquotedEventDescriptionString: Bool { + !contains(where: \.isBracketedDescriptionDelimiter) && Event.Value(eventDescription: self) == .string(self) + } +} + +private extension Character { + + var isBracketedDescriptionDelimiter: Bool { + switch self { + case "[", "]", "\"": + return true + default: + return false + } + } +} diff --git a/Sources/SwiftLexicon/SwiftUI.swift b/Sources/SwiftLexicon/SwiftUI.swift index 3635c87..397cf52 100644 --- a/Sources/SwiftLexicon/SwiftUI.swift +++ b/Sources/SwiftLexicon/SwiftUI.swift @@ -6,57 +6,114 @@ import SwiftUI public extension EnvironmentValues { - + var events: Events { - self[EventsKey.self] + get { self[EventsKey.self] } + set { self[EventsKey.self] = newValue } } - + private struct EventsKey: EnvironmentKey { static let defaultValue: Events = .init() } } public extension View { - - @available( - *, - deprecated, - message: """ - No way as yet of guaranteeing SwiftUI will not coexisit \ - multiple copies of these values, whith the result of ƒ \ - being called multiple times with the same event. - """ - ) - func on(_ events: I..., ƒ: @escaping @MainActor (Event) -> ()) -> some View { - modifier(OnEvents(types: events, ƒ: ƒ)) + + func events(_ events: Events) -> some View { + environment(\.events, events) + } + + func onEvent( + _ event: some I, + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + modifier(OnEvents(predicate: { $0.is(event) }, action: action)) + } + + func onEvent( + _ type: A.Type, + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + modifier(OnEvents(predicate: { $0.is(type) }, action: action)) + } + + func onEvents( + _ events: any I..., + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + onEvents(events, perform: action) + } + + func onEvents( + _ events: [any I], + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + modifier(OnEvents(isEnabled: !events.isEmpty, predicate: { event in + events.contains(where: event.is) + }, action: action)) + } + + func onEvents( + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + modifier(OnEvents(predicate: { _ in true }, action: action)) + } + + func onEvents( + where predicate: @escaping @Sendable (Event) -> Bool, + perform action: @escaping @MainActor @Sendable (Event) -> Void + ) -> some View { + modifier(OnEvents(predicate: predicate, action: action)) + } + + @available(*, deprecated, renamed: "onEvents(_:perform:)") + func on(_ events: any I..., ƒ: @escaping @MainActor @Sendable (Event) -> Void) -> some View { + onEvents(events, perform: ƒ) } } -struct OnEvents: ViewModifier { - - @Environment(\.events) var events - - let types: [I] - let ƒ: @MainActor (Event) -> () - +private struct OnEvents: ViewModifier { + + @Environment(\.events) private var events + @State private var subscriber = Events.Subscriber() + + var isEnabled = true + let predicate: @Sendable (Event) -> Bool + let action: @MainActor @Sendable (Event) -> Void + func body(content: Content) -> some View { - if types.isEmpty { - content - } else { - content.task { - for await event in events.stream { - guard types.contains(where: event.is) else { - continue - } - ƒ(event) - } + // Keep the retained subscriber using the latest closures without + // forcing a resubscribe on every render. + subscriber.update(where: predicate) { event in + await action(event) + } + + return content + .onAppear { + updateSubscriber() } + .onChange(of: events.id, initial: true) { _, _ in + updateSubscriber() + } + .onChange(of: isEnabled, initial: false) { _, _ in + updateSubscriber() + } + .onDisappear { + subscriber.cancel() + } + } + + private func updateSubscriber() { + if isEnabled { + subscriber.subscribe(to: events) + } else { + subscriber.cancel() } } } public extension View { - + func update(_ a: A, _ k: ReferenceWritableKeyPath, to b: B) -> Self { a[keyPath: k] = b return self diff --git a/Sources/lexicon-generate/CodeGenerator.swift b/Sources/lexicon-generate/CodeGenerator.swift index 3004ab0..641252b 100644 --- a/Sources/lexicon-generate/CodeGenerator.swift +++ b/Sources/lexicon-generate/CodeGenerator.swift @@ -53,7 +53,7 @@ struct CodeGeneratorCommand: AsyncParsableCommand { .decodeDocument() .composed(resolving: FileLexiconImportResolver(baseURL: input.deletingLastPathComponent())) guard plan.conflicts.isEmpty else { - throw plan.conflicts.map(\.description).joined(separator: "\n") + throw LexiconError(plan.conflicts.map(\.description).joined(separator: "\n")) } let lexicon = try await Lexicon.from(plan.document) let json = await lexicon.json() diff --git a/Tests/LexiconGeneratorsTests/GoGeneratorTests.swift b/Tests/LexiconGeneratorsTests/GoGeneratorTests.swift index c9bd339..6437ea5 100644 --- a/Tests/LexiconGeneratorsTests/GoGeneratorTests.swift +++ b/Tests/LexiconGeneratorsTests/GoGeneratorTests.swift @@ -227,13 +227,13 @@ private extension GoGeneratorTests { let output = String(data: standardOutput.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let error = String(data: standardError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" if process.terminationStatus != 0 { - throw """ + throw LexiconError(""" Command failed (\(process.terminationStatus)): \(command) stdout: \(output) stderr: \(error) - """ + """) } } } @@ -246,7 +246,7 @@ extension String { func file() throws -> Data { guard let url = Bundle.module.url(forResource: "Resources/\(self)", withExtension: nil) else { - throw "Could not find '\(self)'" + throw LexiconError("Could not find '\(self)'") } return try Data(contentsOf: url) } diff --git a/Tests/LexiconGeneratorsTests/RustGeneratorTests.swift b/Tests/LexiconGeneratorsTests/RustGeneratorTests.swift index f3d3bf0..ceb47f0 100644 --- a/Tests/LexiconGeneratorsTests/RustGeneratorTests.swift +++ b/Tests/LexiconGeneratorsTests/RustGeneratorTests.swift @@ -180,13 +180,13 @@ private extension RustGeneratorTests { let output = String(data: standardOutput.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let error = String(data: standardError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" if process.terminationStatus != 0 { - throw """ + throw LexiconError(""" Command failed (\(process.terminationStatus)): \(command) stdout: \(output) stderr: \(error) - """ + """) } } } diff --git a/Tests/LexiconLSPTests/LexiconLSPCommandTests.swift b/Tests/LexiconLSPTests/LexiconLSPCommandTests.swift index 1a68907..7b4d836 100644 --- a/Tests/LexiconLSPTests/LexiconLSPCommandTests.swift +++ b/Tests/LexiconLSPTests/LexiconLSPCommandTests.swift @@ -3,6 +3,7 @@ // import Foundation +import Lexicon import Testing #if canImport(Darwin) import Darwin @@ -218,7 +219,7 @@ private struct LSPProcess { output = Pipe() error = Pipe() guard let outputFileDescriptorFlags = Self.enableNonblockingReads(for: output.fileHandleForReading) else { - throw "Could not enable nonblocking reads for lexicon-lsp stdout" + throw LexiconError("Could not enable nonblocking reads for lexicon-lsp stdout") } self.outputFileDescriptorFlags = outputFileDescriptorFlags process.executableURL = Self.packageRoot().appendingPathComponent(".build/debug/lexicon-lsp") @@ -252,7 +253,7 @@ private struct LSPProcess { let data = outputBuffer let stderr = String(data: error.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" guard process.terminationStatus == 0 else { - throw "lexicon-lsp exited with \(process.terminationStatus).\n\(stderr)" + throw LexiconError("lexicon-lsp exited with \(process.terminationStatus).\n\(stderr)") } return try Self.messages(in: data) } @@ -283,7 +284,7 @@ private struct LSPProcess { } Thread.sleep(forTimeInterval: 0.01) } - throw "Timed out waiting for lexicon-lsp response \(id)" + throw LexiconError("Timed out waiting for lexicon-lsp response \(id)") } func didOpen(uri: URL, languageID: String, text: String) throws { diff --git "a/Tests/LexiconTests/CLI\342\204\242.swift" "b/Tests/LexiconTests/CLI\342\204\242.swift" index 41e0d5e..f2a37e8 100644 --- "a/Tests/LexiconTests/CLI\342\204\242.swift" +++ "b/Tests/LexiconTests/CLI\342\204\242.swift" @@ -5,112 +5,134 @@ import Testing import Foundation @testable import Lexicon +import SwiftLexicon @Suite struct CLI™ { - + @Test func test_against_session_record() async throws { - + let session: CLI.Session = try "testCLI".json() - - var action: CLI.Session.Event? - var expected: CLI.Session.Event? - - for event in session.events { // TODO: run in parallel - switch event.detail.id { // TODO: generalise ↓ with composable lexicons - - case "app.event.cli.append", - "app.event.cli.backspace", - "app.event.cli.enter", - "app.event.cli.select.next", - "app.event.cli.select.previous" - : - action = event - expected = nil - - case "app.event.cli.did.change": - expected = event - - default: - break - } - guard let event = action, let expected = expected else { - continue - } - defer { - action = nil - } - - func test(_ ƒ: (inout CLI) async throws -> ()) async throws { - var cli = try await event.cli() - try await ƒ(&cli) - let record = await cli.record() - if record != expected.record { - let video = session.video.flatMap { o in - o.url.absoluteString + "?t=\(Int(o.start + event.time))\n" - } ?? "" - let message = try """ - - 🐞 Failed \(event): - \(video) - • EVENT: - \(event.json().string()) - - • EXPECTED: - \(expected.json().string()) - - • ACTUAL: - \(record.json().string()) - """ - - throw message + let expectations = try session.replayExpectations() + + try await withThrowingTaskGroup(of: Void.self) { group in + for expectation in expectations { + group.addTask { + try await expectation.replay(video: session.video) } } - - let (id, data) = event.detail - - try await test { cli in - switch id { - - case "app.event.cli.append": - let character = try data["app.event.cli.append"].try().first.try() - await cli.append(character) - - case "app.event.cli.backspace": - await cli.backspace() - - case "app.event.cli.enter": - await cli.enter() - - case "app.event.cli.select.next": - cli.selectNext() - - case "app.event.cli.select.previous": - cli.selectPrevious() - - default: - throw "unexpected test event" + try await group.waitForAll() + } + } +} + +private struct CLIReplayExpectation: Sendable { + var action: CLI.Session.Event + var expected: CLI.Session.Event +} + +private enum CLIReplayEvent: Sendable { + case action(Action, EventDetail) + case didChange + case ignored + + enum Action: String, Sendable { + case append = "app.event.cli.append" + case backspace = "app.event.cli.backspace" + case enter = "app.event.cli.enter" + case selectNext = "app.event.cli.select.next" + case selectPrevious = "app.event.cli.select.previous" + } + + init(_ event: CLI.Session.Event) throws { + let detail = try EventDetail(bracketed: event.description) + if let action = Action(rawValue: detail.id) { + self = .action(action, detail) + } else if detail.id == "app.event.cli.did.change" { + self = .didChange + } else { + self = .ignored + } + } +} + +private extension CLI.Session { + + func replayExpectations() throws -> [CLIReplayExpectation] { + var pending: CLI.Session.Event? + var expectations: [CLIReplayExpectation] = [] + + for event in events { + switch try CLIReplayEvent(event) { + case .action: + pending = event + case .didChange: + if let action = pending { + expectations.append(.init(action: action, expected: event)) + pending = nil } + case .ignored: + break } } + + return expectations } } -extension CLI.Session.Event { - - private static let brackets = CharacterSet(charactersIn: "[]") - - var detail: (id: String, data: [String: String]) { - let substrings = description.components(separatedBy: Self.brackets).filter(\.isEmpty.not) - var id = "" - var data: [String: String] = [:] - for (i, (k, v)) in zip(substrings, substrings.dropFirst()).enumerated() where i.isMultiple(of: 2) { - id = (i == 0) ? k : (id + k) - data[id] = v +private extension CLIReplayExpectation { + + func replay(video: CLI.Session.Video?) async throws { + var cli = try await action.cli() + let event = try CLIReplayEvent(action) + guard case .action(let action, let detail) = event else { + throw LexiconError("unexpected test event") + } + + try await cli.apply(action, detail: detail) + let record = await cli.record() + guard record == expected.record else { + throw try failureError(actual: record, video: video) + } + } + + func failureError(actual: CLI.Session.Record, video: CLI.Session.Video?) throws -> LexiconError { + let video = video.flatMap { o in + o.url.absoluteString + "?t=\(Int(o.start + action.time))\n" + } ?? "" + return try LexiconError(""" + + 🐞 Failed \(action): + \(video) + • EVENT: + \(action.json().string()) + + • EXPECTED: + \(expected.json().string()) + + • ACTUAL: + \(actual.json().string()) + """) + } +} + +private extension CLI { + + mutating func apply(_ action: CLIReplayEvent.Action, detail: EventDetail) async throws { + switch action { + case .append: + let character = try detail.data[action.rawValue].try().string.try().first.try() + await append(character) + case .backspace: + await backspace() + case .enter: + await enter() + case .selectNext: + selectNext() + case .selectPrevious: + selectPrevious() } - id = substrings.enumerated().filter{ $0.offset.isMultiple(of: 2) }.map(\.element).joined() - return (id, data) } } diff --git a/Tests/LexiconTests/LexiconCLICommandTests.swift b/Tests/LexiconTests/LexiconCLICommandTests.swift index f45a9e0..e83c0c7 100644 --- a/Tests/LexiconTests/LexiconCLICommandTests.swift +++ b/Tests/LexiconTests/LexiconCLICommandTests.swift @@ -5,6 +5,7 @@ import Testing #if !os(Android) import Foundation +import Lexicon @Suite @@ -243,7 +244,7 @@ private extension LexiconCLICommandTests { let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" guard process.terminationStatus == 0 else { - throw "lexicon \(arguments.joined(separator: " ")) failed: \(error)\n\(output)" + throw LexiconError("lexicon \(arguments.joined(separator: " ")) failed: \(error)\n\(output)") } return (output, error) } diff --git a/Tests/LexiconTests/READMEExampleTests.swift b/Tests/LexiconTests/READMEExampleTests.swift index 25ad19e..6adff6a 100644 --- a/Tests/LexiconTests/READMEExampleTests.swift +++ b/Tests/LexiconTests/READMEExampleTests.swift @@ -134,7 +134,7 @@ private extension READMEExampleTests { forResource: "Resources/READMEExamples/\(filename)", withExtension: nil ) else { - throw "README example resource not found: \(filename)" + throw LexiconError("README example resource not found: \(filename)") } return try String(contentsOf: url, encoding: .utf8).droppingTrailingNewline() } diff --git a/Tests/LexiconTests/StringSortingTests.swift b/Tests/LexiconTests/StringSortingTests.swift new file mode 100644 index 0000000..d0e4e7f --- /dev/null +++ b/Tests/LexiconTests/StringSortingTests.swift @@ -0,0 +1,33 @@ +// +// github.com/screensailor 2026 +// + +import Testing +@testable import Lexicon + +@Suite +struct StringSortingTests { + + @Test + func sortedByLocalizedStandard_uses_standard_numeric_order() { + let values = ["item 10", "item 2", "item 1"] + + #expect(values.sortedByLocalizedStandard() == ["item 1", "item 2", "item 10"]) + #expect(values.sortedByLocalizedStandard(.orderedDescending) == ["item 10", "item 2", "item 1"]) + } + + @Test + func sortedByLocalizedStandard_accepts_string_protocol_key_paths() { + let values = [ + Example(name: "item 10"[...]), + Example(name: "item 2"[...]), + Example(name: "item 1"[...]) + ] + + #expect(values.sortedByLocalizedStandard(by: \.name).map(\.name) == ["item 1", "item 2", "item 10"]) + } +} + +private struct Example { + let name: Substring +} diff --git "a/Tests/LexiconTests/TaskPaper\342\204\242.swift" "b/Tests/LexiconTests/TaskPaper\342\204\242.swift" index 641bfa7..4b72530 100644 --- "a/Tests/LexiconTests/TaskPaper\342\204\242.swift" +++ "b/Tests/LexiconTests/TaskPaper\342\204\242.swift" @@ -5,14 +5,14 @@ import Testing @Suite struct TaskPaper™ { - + @Test func test_root_only_taskpaper() async throws { - + let graph = try TaskPaper(taskpaper_example).decode() - + let taskpaper = TaskPaper.encode(graph) - + #expect(taskpaper == taskpaper_example_clean) } @@ -48,6 +48,65 @@ struct TaskPaper™ { #expect(text.substring(utf16: range) == reference) } } + + @Test + func test_encode_sorts_type_references_lexicographically() { + let graph = Lexicon.Graph( + root: .init( + name: "root", + children: [ + "item": .init( + name: "item", + type: ["root.z", "root.a", "root.m"] + ) + ] + ), + date: .init(timeIntervalSinceReferenceDate: 0) + ) + + #expect(TaskPaper.encode(graph) == """ + root: + item: + + root.a + + root.m + + root.z + """) + } + + @Test + func lexicon_syntax_requires_colons_for_lemmas() throws { + let graph = try TaskPaper(""" + root: + ignored + actual: + """).decode() + + #expect(graph.root.children.keys == ["actual"]) + } + + @Test + func plain_text_outline_syntax_accepts_lemmas_without_colons() throws { + let graph = try TaskPaper(""" + root + child + """, options: .plainTextOutline).decode() + + #expect(TaskPaper.encode(graph) == """ + root: + child: + """) + } + + @Test + func source_map_uses_selected_lemma_syntax() throws { + #expect(try TaskPaper("root").sourceMap().lines.isEmpty) + #expect( + try TaskPaper("root", options: .plainTextOutline) + .sourceMap() + .lines + .map(\.content) == [.lemma(name: "root")] + ) + } } private let taskpaper_example = """ diff --git a/Tests/LexiconTests/util/String.swift b/Tests/LexiconTests/util/String.swift index 9354958..eec1272 100644 --- a/Tests/LexiconTests/util/String.swift +++ b/Tests/LexiconTests/util/String.swift @@ -16,7 +16,7 @@ extension String { func file() throws -> Data { guard let url = Bundle.module.url(forResource: "Resources/\(self)", withExtension: nil) else { - throw "Could not find '\(self)'" + throw LexiconError("Could not find '\(self)'") } return try Data(contentsOf: url) } diff --git a/Tests/SwiftLexiconTests/EventSubscriberTests.swift b/Tests/SwiftLexiconTests/EventSubscriberTests.swift new file mode 100644 index 0000000..ea4a234 --- /dev/null +++ b/Tests/SwiftLexiconTests/EventSubscriberTests.swift @@ -0,0 +1,378 @@ +// +// github.com/screensailor 2026 +// + +import Foundation +import Testing +@testable import SwiftLexicon + +@Suite +struct EventSubscriberTests { + + @Test + func subscriber_delivers_matching_events() async throws { + let events = Events() + let log = EventLog() + let subscriber = Events.Subscriber(where: { $0.is(test.one) }) { event in + await log.append(event) + } + defer { + subscriber.cancel() + } + + subscriber.subscribe(to: events) + await events.send(Event(test.two)).value + await events.send(Event(test.one)).value + + await log.wait(for: 1) + + #expect(await log.snapshot() == ["test.one"]) + } + + @Test + func subscriber_is_idempotent_when_subscribed_to_the_same_events() async throws { + let events = Events() + let log = EventLog() + let subscriber = Events.Subscriber(where: { $0.is(test.one) }) { event in + await log.append(event) + } + defer { + subscriber.cancel() + } + + subscriber.subscribe(to: events) + subscriber.subscribe(to: events) + await events.send(Event(test.one)).value + + await log.wait(for: 1) + + #expect(subscriber.isSubscribed) + #expect(await log.snapshot() == ["test.one"]) + } + + @Test + func subscriber_resubscribes_to_new_events_and_cancels_the_old_subscription() async throws { + let first = Events() + let second = Events() + let log = EventLog() + let subscriber = Events.Subscriber(where: { $0.is(test.one) }) { event in + await log.append(event) + } + defer { + subscriber.cancel() + } + + subscriber.subscribe(to: first) + await first.send(Event(test.one["initial"])).value + await log.wait(for: 1) + + subscriber.subscribe(to: second) + await first.send(Event(test.one["stale"])).value + await second.send(Event(test.one["fresh"])).value + + await log.wait(for: 2) + + #expect(await log.snapshot() == ["test.one[initial]", "test.one[fresh]"]) + } + + @Test + func subscriber_updates_its_handler_without_resubscribing() async throws { + let events = Events() + let log = EventLog() + let subscriber = Events.Subscriber() + defer { + subscriber.cancel() + } + + subscriber.subscribe(to: events, where: { $0.is(test.one) }) { event in + await log.append(event) + } + subscriber.update(where: { $0.is(test.two) }) { event in + await log.append(event) + } + + await events.send(Event(test.one)).value + await events.send(Event(test.two)).value + + await log.wait(for: 1) + + #expect(await log.snapshot() == ["test.two"]) + } + + @Test + func subscriber_cancel_stops_delivery() async throws { + let events = Events() + let log = EventLog() + let subscriber = Events.Subscriber(where: { $0.is(test.one) }) { event in + await log.append(event) + } + + subscriber.subscribe(to: events) + subscriber.cancel() + await events.send(Event(test.one)).value + + #expect(!subscriber.isSubscribed) + #expect(await log.snapshot().isEmpty) + } + + @Test + func subscriber_subscribe_to_nil_cancels_subscription() async throws { + let events = Events() + let log = EventLog() + let subscriber = Events.Subscriber(where: { $0.is(test.one) }) { event in + await log.append(event) + } + + subscriber.subscribe(to: events) + subscriber.subscribe(to: nil) + await events.send(Event(test.one)).value + + #expect(!subscriber.isSubscribed) + #expect(await log.snapshot().isEmpty) + } + + @Test + func streams_each_receive_each_event_once_and_finish() async throws { + let events = Events() + let firstStream = events.stream + let secondStream = events.stream + let first = Task { + await collect(2, from: firstStream) + } + let second = Task { + await collect(2, from: secondStream) + } + defer { + first.cancel() + second.cancel() + events.finish() + } + + let one = Event(test.one) + let two = Event(test.two) + await events.send(one).value + await events.send(two).value + + #expect(await first.value.map(\.id) == [one.id, two.id]) + #expect(await second.value.map(\.id) == [one.id, two.id]) + + let finishingStream = events.stream + let finished = Task { + await collectUntilFinished(from: finishingStream) + } + let final = Event(test.one.more) + await events.send(final).value + events.finish() + + #expect(await finished.value.map(\.id) == [final.id]) + } + + @Test + func direct_subscription_filters_and_cancels_delivery() async throws { + let events = Events() + let log = EventLog() + let subscription = events.subscribe(where: { $0.is(test.one) }) { event in + await log.append(event) + } + defer { + subscription.cancel() + events.finish() + } + + await events.send(Event(test.two)).value + await events.send(Event(test.one["matched"])).value + + await log.wait(for: 1) + #expect(await log.snapshot() == ["test.one[matched]"]) + + subscription.cancel() + await events.send(Event(test.one["stale"])).value + + #expect(await log.snapshot() == ["test.one[matched]"]) + } + + @Test + func send_operators_publish_lemma_k_and_existing_event_values() async throws { + let events = Events() + let stream = events.stream + let collected = Task { + await collect(3, from: stream) + } + defer { + collected.cancel() + events.finish() + } + + let explicit = Event(test.two["explicit"]) + test.one >> events + test.one["indexed"] >> events + explicit >> events + + #expect(await collected.value.map(\.description) == [ + "test.one", + "test.one[indexed]", + "test.two[explicit]", + ]) + } + + @Test @MainActor + func context_subscription_uses_weak_context_and_stops_after_context_deinitializes() async throws { + let events = Events() + let log = EventLog() + let deinitSignal = DeinitSignal() + var probe: EventContextProbe? = EventContextProbe(events: events, log: log, deinitSignal: deinitSignal) + let weakProbe = WeakBox(probe) + let makeHandler = try probe.require().context() + let handler = makeHandler { object, event in + await object.record(event) + } + let subscription = test.one >> handler + defer { + subscription.cancel() + events.finish() + } + + await events.send(Event(test.two)).value + await events.send(Event(test.one)).value + + await log.wait(for: 1) + #expect(await log.snapshot() == ["test.one"]) + + probe = nil + await deinitSignal.wait() + #expect(weakProbe.value == nil) + + await events.send(Event(test.one)).value + + #expect(await log.snapshot() == ["test.one"]) + } +} + +private actor EventLog { + + private struct Waiter { + var count: Int + var continuation: CheckedContinuation + } + + private var descriptions: [String] = [] + private var waiters: [Waiter] = [] + + func append(_ event: Event) { + descriptions.append(event.description) + resumeReadyWaiters() + } + + func wait(for count: Int) async { + if descriptions.count >= count { + return + } + await withCheckedContinuation { continuation in + waiters.append(Waiter(count: count, continuation: continuation)) + } + } + + func snapshot() -> [String] { + descriptions + } + + private func resumeReadyWaiters() { + let ready = waiters.filter { descriptions.count >= $0.count } + waiters.removeAll { descriptions.count >= $0.count } + for waiter in ready { + waiter.continuation.resume() + } + } +} + +private func collect(_ count: Int, from stream: AsyncStream) async -> [Event] { + var events: [Event] = [] + for await event in stream { + events.append(event) + if events.count == count { + break + } + } + return events +} + +private func collectUntilFinished(from stream: AsyncStream) async -> [Event] { + var events: [Event] = [] + for await event in stream { + events.append(event) + } + return events +} + +@MainActor private final class EventContextProbe: EventContext, @unchecked Sendable { + + let events: Events + private let log: EventLog + private let deinitSignal: DeinitSignal + + init(events: Events, log: EventLog, deinitSignal: DeinitSignal) { + self.events = events + self.log = log + self.deinitSignal = deinitSignal + } + + deinit { + deinitSignal.signal() + } + + nonisolated var description: String { + "EventContextProbe" + } + + func record(_ event: Event) async { + await log.append(event) + } +} + +private extension Optional { + + func require() throws -> Wrapped { + try #require(self) + } +} + +private final class WeakBox { + + weak var value: Value? + + init(_ value: Value?) { + self.value = value + } +} + +private final class DeinitSignal: @unchecked Sendable { + + private let lock = NSLock() + private var didSignal = false + private var continuations: [CheckedContinuation] = [] + + func signal() { + lock.lock() + didSignal = true + let pending = continuations + continuations.removeAll() + lock.unlock() + + for continuation in pending { + continuation.resume() + } + } + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if didSignal { + lock.unlock() + continuation.resume() + } else { + continuations.append(continuation) + lock.unlock() + } + } + } +} diff --git a/Tests/SwiftLexiconTests/EventSubscriptionSetBuilderTests.swift b/Tests/SwiftLexiconTests/EventSubscriptionSetBuilderTests.swift new file mode 100644 index 0000000..77b4fbb --- /dev/null +++ b/Tests/SwiftLexiconTests/EventSubscriptionSetBuilderTests.swift @@ -0,0 +1,40 @@ +// +// github.com/screensailor 2026 +// + +import Testing +@testable import SwiftLexicon + +@Suite +struct EventSubscriptionSetBuilderTests { + + @Test + func builder_collects_subscription_control_flow() { + let subscriptions = subscriptions(includeOptional: true) + defer { + subscriptions.forEach { $0.cancel() } + } + + #expect(subscriptions.count == 4) + } + + @Test + func builder_omits_unmatched_optional_branch() { + let subscriptions = subscriptions(includeOptional: false) + defer { + subscriptions.forEach { $0.cancel() } + } + + #expect(subscriptions.count == 3) + } +} + +@Bear private func subscriptions(includeOptional: Bool) -> Mind { + EventSubscription(Task {}) + if includeOptional { + EventSubscription(Task {}) + } + for _ in 0..<2 { + EventSubscription(Task {}) + } +} diff --git a/Tests/SwiftLexiconTests/KBracketedTests.swift b/Tests/SwiftLexiconTests/KBracketedTests.swift new file mode 100644 index 0000000..8af735a --- /dev/null +++ b/Tests/SwiftLexiconTests/KBracketedTests.swift @@ -0,0 +1,96 @@ +// +// github.com/screensailor 2026 +// + +import Testing +@testable import SwiftLexicon + +@Suite +struct KBracketedTests { + + @Test + func exposes_bracketed_detail() { + let k = test.one[1].more["two"] + let detail = k.detail + let event = Event(k) + + #expect(k.bracketed == "test.one[1].more[two]") + #expect(String(describing: k) == "test.one[1].more[two]") + #expect(detail.id == "test.one.more") + #expect(detail.data["test.one"]?.int == 1) + #expect(detail.data["test.one.more"]?.string == "two") + #expect(event.detail == detail) + } + + @Test + func initializes_from_bracketed_description() throws { + let k = try K(bracketed: "test.one[1].more[two]") + let one: Int = try k[test.one] + let more: String = try k[test.one.more] + + #expect(k.bracketed == "test.one[1].more[two]") + #expect(k(\.id) == "test.one[1].more[two]") + #expect(k(\.L) == test.one.more) + #expect(one == 1) + #expect(more == "two") + } + + @Test + func bracketed_description_quotes_ambiguous_strings() throws { + for value in ["true", "null", "1", "1.5", "a]b", #"a"b"#] { + let k = test.one[value] + let reparsed = try K(bracketed: k.bracketed) + let reparsedValue: String = try reparsed[test.one] + + #expect(reparsed.detail == k.detail) + #expect(reparsedValue == value) + } + } + + @Test + func parses_scalar_values_from_brackets() throws { + let k = try K(bracketed: "test.one[true].more[null].time[1.5]") + let detail = k.detail + + #expect(detail.id == "test.one.more.time") + #expect(detail.data["test.one"]?.bool == true) + #expect(detail.data["test.one.more"]?.isNull == true) + #expect(detail.data["test.one.more.time"]?.double == 1.5) + } + + @Test + func parses_nested_json_values_from_brackets() throws { + let k = try K(bracketed: #"test.one[[1, "two"]]"#) + let value = try #require(k.detail.data["test.one"]) + let reparsed = try K(bracketed: k.bracketed) + + #expect(value.array?[0].int == 1) + #expect(value.array?[1].string == "two") + #expect(k.bracketed == #"test.one[[1,"two"]]"#) + #expect(reparsed.detail == k.detail) + } + + @Test + func parses_json_string_brackets_inside_nested_values() throws { + let k = try K(bracketed: #"test.one[[{"text":"left]right"}]]"#) + let value = try #require(k.detail.data["test.one"]) + + #expect(value.array?[0].object?["text"]?.string == "left]right") + #expect(try K(bracketed: k.bracketed).detail == k.detail) + } + + @Test + func rejects_malformed_bracketed_descriptions() throws { + #expect(throws: KBracketedDescriptionError.missingClosingBracket("test.one[two")) { + try EventDetail(bracketed: "test.one[two") + } + + #expect(throws: KBracketedDescriptionError.unexpectedClosingBracket("test.one]")) { + try EventDetail(bracketed: "test.one]") + } + + #expect(throws: KBracketedDescriptionError.emptyIdentifier("")) { + try EventDetail(bracketed: "") + } + } +} diff --git "a/Tests/SwiftLexiconTests/SwiftLexicon\342\204\242.swift" "b/Tests/SwiftLexiconTests/SwiftLexicon\342\204\242.swift" index 83edf1c..6522f60 100644 --- "a/Tests/SwiftLexiconTests/SwiftLexicon\342\204\242.swift" +++ "b/Tests/SwiftLexiconTests/SwiftLexicon\342\204\242.swift" @@ -53,7 +53,7 @@ struct SwiftLexicon™ { #expect(l == test.one.more.time.one) - #expect(k(\.id) == "test.one[1].more[2].time[3].one[0]") + #expect(k(\.id) == #"test.one[1].more[2].time["3"].one[0]"#) let events = Events() let results = AsyncChannel() diff --git a/Tests/SwiftLexiconTests/SwiftUISupportTests.swift b/Tests/SwiftLexiconTests/SwiftUISupportTests.swift new file mode 100644 index 0000000..f060df2 --- /dev/null +++ b/Tests/SwiftLexiconTests/SwiftUISupportTests.swift @@ -0,0 +1,39 @@ +// +// github.com/screensailor 2026 +// + +#if canImport(SwiftUI) +import SwiftUI +import Testing +@testable import SwiftLexicon + +@Suite +struct SwiftUISupportTests { + + @Test + @MainActor + func environment_events_can_be_injected() { + let events = Events() + var values = EnvironmentValues() + + values.events = events + + #expect(values.events === events) + } + + @Test + @MainActor + func event_modifiers_accept_lexicon_values_and_types() { + let events = Events() + let view = EmptyView() + .events(events) + .onEvent(test.one) { _ in } + .onEvent(I_test_one.self) { _ in } + .onEvents(test.one, test.two) { _ in } + .onEvents { _ in } + .onEvents(where: { _ in true }) { _ in } + + _ = view + } +} +#endif diff --git a/Tests/SwiftLexiconTests/util.swift b/Tests/SwiftLexiconTests/util.swift index 7584087..8939986 100644 --- a/Tests/SwiftLexiconTests/util.swift +++ b/Tests/SwiftLexiconTests/util.swift @@ -3,6 +3,7 @@ // import Foundation +import Lexicon extension String { @@ -12,7 +13,7 @@ extension String { func file() throws -> Data { guard let url = Bundle.module.url(forResource: "Resources/\(self)", withExtension: nil) else { - throw "Could not find '\(self)'" + throw LexiconError("Could not find '\(self)'") } return try Data(contentsOf: url) }