diff --git a/Sources/LeafKit/Exports.swift b/Sources/LeafKit/Exports.swift index b1503b3c..66352676 100644 --- a/Sources/LeafKit/Exports.swift +++ b/Sources/LeafKit/Exports.swift @@ -1,9 +1,10 @@ +import NIOConcurrencyHelpers /// Various helper identities for convenience extension Character { // MARK: - LeafToken specific identities (Internal) - static var tagIndicator: Character = .octothorpe - + static let tagIndicator = NIOLockedValueBox(Character.octothorpe) + var isValidInTagName: Bool { return self.isLowercaseLetter || self.isUppercaseLetter diff --git a/Sources/LeafKit/LeafAST.swift b/Sources/LeafKit/LeafAST.swift index 6e4e6622..47e3cfa9 100644 --- a/Sources/LeafKit/LeafAST.swift +++ b/Sources/LeafKit/LeafAST.swift @@ -1,7 +1,7 @@ import NIO /// `LeafAST` represents a "compiled," grammatically valid Leaf template (which may or may not be fully resolvable or erroring) -public struct LeafAST: Hashable { +public struct LeafAST: Hashable, Sendable { // MARK: - Public public func hash(into hasher: inout Hasher) { hasher.combine(name) } diff --git a/Sources/LeafKit/LeafCache/DefaultLeafCache.swift b/Sources/LeafKit/LeafCache/DefaultLeafCache.swift index d77d890d..4b48204e 100644 --- a/Sources/LeafKit/LeafCache/DefaultLeafCache.swift +++ b/Sources/LeafKit/LeafCache/DefaultLeafCache.swift @@ -1,11 +1,33 @@ import NIOConcurrencyHelpers import NIO -public final class DefaultLeafCache: SynchronousLeafCache { +/// `@unchecked Sendable` because uses locks to guarantee Sendability. +public final class DefaultLeafCache: SynchronousLeafCache, @unchecked Sendable { // MARK: - Public - `LeafCache` Protocol Conformance - + + var __isEnabled = true + /// Global setting for enabling or disabling the cache + public var _isEnabled: Bool { + get { + self.lock.withLock { + self.__isEnabled + } + } + set(newValue) { + self.lock.withLock { + self.__isEnabled = newValue + } + } + } /// Global setting for enabling or disabling the cache - public var isEnabled: Bool = true + public var isEnabled: Bool { + get { + self._isEnabled + } + set(newValue) { + self._isEnabled = newValue + } + } /// Current count of cached documents public var count: Int { self.lock.withLock { cache.count } } @@ -25,17 +47,16 @@ public final class DefaultLeafCache: SynchronousLeafCache { on loop: any EventLoop, replace: Bool = false ) -> EventLoopFuture { - // future fails if caching is enabled - guard isEnabled else { return loop.makeSucceededFuture(document) } - - self.lock.lock() - defer { self.lock.unlock() } - // return an error if replace is false and the document name is already in cache - switch (self.cache.keys.contains(document.name),replace) { + self.lock.withLock { + // future fails if caching is enabled + guard __isEnabled else { return loop.makeSucceededFuture(document) } + // return an error if replace is false and the document name is already in cache + switch (self.cache.keys.contains(document.name),replace) { case (true, false): return loop.makeFailedFuture(LeafError(.keyExists(document.name))) default: self.cache[document.name] = document + } + return loop.makeSucceededFuture(document) } - return loop.makeSucceededFuture(document) } /// - Parameters: @@ -46,10 +67,10 @@ public final class DefaultLeafCache: SynchronousLeafCache { documentName: String, on loop: any EventLoop ) -> EventLoopFuture { - guard isEnabled == true else { return loop.makeSucceededFuture(nil) } - self.lock.lock() - defer { self.lock.unlock() } - return loop.makeSucceededFuture(self.cache[documentName]) + self.lock.withLock { + guard __isEnabled == true else { return loop.makeSucceededFuture(nil) } + return loop.makeSucceededFuture(self.cache[documentName]) + } } /// - Parameters: @@ -61,14 +82,12 @@ public final class DefaultLeafCache: SynchronousLeafCache { _ documentName: String, on loop: any EventLoop ) -> EventLoopFuture { - guard isEnabled == true else { return loop.makeFailedFuture(LeafError(.cachingDisabled)) } - - self.lock.lock() - defer { self.lock.unlock() } - - guard self.cache[documentName] != nil else { return loop.makeSucceededFuture(nil) } - self.cache[documentName] = nil - return loop.makeSucceededFuture(true) + self.lock.withLock { + guard __isEnabled == true else { return loop.makeFailedFuture(LeafError(.cachingDisabled)) } + guard self.cache[documentName] != nil else { return loop.makeSucceededFuture(nil) } + self.cache[documentName] = nil + return loop.makeSucceededFuture(true) + } } // MARK: - Internal Only @@ -78,9 +97,9 @@ public final class DefaultLeafCache: SynchronousLeafCache { /// Blocking file load behavior internal func retrieve(documentName: String) throws -> LeafAST? { - guard isEnabled == true else { throw LeafError(.cachingDisabled) } self.lock.lock() defer { self.lock.unlock() } + guard __isEnabled == true else { throw LeafError(.cachingDisabled) } let result = self.cache[documentName] guard result != nil else { throw LeafError(.noValueForKey(documentName)) } return result diff --git a/Sources/LeafKit/LeafCache/LeafCache.swift b/Sources/LeafKit/LeafCache/LeafCache.swift index 9d9fa0b5..553b03bd 100644 --- a/Sources/LeafKit/LeafCache/LeafCache.swift +++ b/Sources/LeafKit/LeafCache/LeafCache.swift @@ -12,7 +12,8 @@ import NIO /// adherents where the cache store itself is not a bottleneck. /// /// `LeafAST.name` is to be used in all cases as the key for retrieving cached documents. -public protocol LeafCache { +@preconcurrency +public protocol LeafCache: Sendable { /// Global setting for enabling or disabling the cache var isEnabled : Bool { get set } /// Current count of cached documents diff --git a/Sources/LeafKit/LeafConfiguration.swift b/Sources/LeafKit/LeafConfiguration.swift index 5c0c7f38..50cede97 100644 --- a/Sources/LeafKit/LeafConfiguration.swift +++ b/Sources/LeafKit/LeafConfiguration.swift @@ -1,9 +1,10 @@ import Foundation +import NIOConcurrencyHelpers /// General configuration of Leaf /// - Sets the default View directory where templates will be looked for /// - Guards setting the global tagIndicator (default `#`). -public struct LeafConfiguration { +public struct LeafConfiguration: Sendable { /// Initialize Leaf with the default tagIndicator `#` and unfound imports throwing an exception /// - Parameter rootDirectory: Default directory where templates will be found @@ -23,9 +24,9 @@ public struct LeafConfiguration { /// - Parameter tagIndicator: Unique tagIndicator - may only be set once. /// - Parameter ignoreUnfoundImports: Ignore unfound imports - may only be set once. public init(rootDirectory: String, tagIndicator: Character, ignoreUnfoundImports: Bool) { - if !Self.started { - Character.tagIndicator = tagIndicator - Self.started = true + if !Self.started.withLockedValue({ $0 }) { + Character.tagIndicator.withLockedValue { $0 = tagIndicator } + Self.started.withLockedValue { $0 = true } } self._rootDirectory = rootDirectory self._ignoreUnfoundImports = ignoreUnfoundImports @@ -42,53 +43,53 @@ public struct LeafConfiguration { } public static var encoding: String.Encoding { - get { _encoding } - set { if !Self.running { _encoding = newValue } } + get { _encoding.withLockedValue { $0 } } + set { if !Self.running { _encoding.withLockedValue { $0 = newValue } } } } public static var boolFormatter: (Bool) -> String { - get { _boolFormatter } - set { if !Self.running { _boolFormatter = newValue } } + get { _boolFormatter.withLockedValue { $0 } } + set { if !Self.running { _boolFormatter.withLockedValue { $0 = newValue } } } } public static var intFormatter: (Int) -> String { - get { _intFormatter } - set { if !Self.running { _intFormatter = newValue } } + get { _intFormatter.withLockedValue { $0 } } + set { if !Self.running { _intFormatter.withLockedValue { $0 = newValue } } } } public static var doubleFormatter: (Double) -> String { - get { _doubleFormatter } - set { if !Self.running { _doubleFormatter = newValue } } + get { _doubleFormatter.withLockedValue { $0 } } + set { if !Self.running { _doubleFormatter.withLockedValue { $0 = newValue } } } } public static var nilFormatter: () -> String { - get { _nilFormatter } - set { if !Self.running { _nilFormatter = newValue } } + get { _nilFormatter.withLockedValue { $0 } } + set { if !Self.running { _nilFormatter.withLockedValue { $0 = newValue } } } } public static var voidFormatter: () -> String { - get { _voidFormatter } - set { if !Self.running { _voidFormatter = newValue } } + get { _voidFormatter.withLockedValue { $0 } } + set { if !Self.running { _voidFormatter.withLockedValue { $0 = newValue } } } } public static var stringFormatter: (String) -> String { - get { _stringFormatter } - set { if !Self.running { _stringFormatter = newValue } } + get { _stringFormatter.withLockedValue { $0 } } + set { if !Self.running { _stringFormatter.withLockedValue { $0 = newValue } } } } public static var arrayFormatter: ([String]) -> String { - get { _arrayFormatter } - set { if !Self.running { _arrayFormatter = newValue } } + get { _arrayFormatter.withLockedValue { $0 } } + set { if !Self.running { _arrayFormatter.withLockedValue { $0 = newValue } } } } public static var dictFormatter: ([String: String]) -> String { - get { _dictFormatter } - set { if !Self.running { _dictFormatter = newValue } } + get { _dictFormatter.withLockedValue { $0 } } + set { if !Self.running { _dictFormatter.withLockedValue { $0 = newValue } } } } public static var dataFormatter: (Data) -> String? { - get { _dataFormatter } - set { if !Self.running { _dataFormatter = newValue } } + get { _dataFormatter.withLockedValue { $0 } } + set { if !Self.running { _dataFormatter.withLockedValue { $0 = newValue } } } } // MARK: - Internal/Private Only @@ -99,27 +100,29 @@ public struct LeafConfiguration { internal var _ignoreUnfoundImports: Bool { willSet { assert(!accessed, "Changing property after LeafConfiguration has been read has no effect") } } - - internal static var _encoding: String.Encoding = .utf8 - internal static var _boolFormatter: (Bool) -> String = { $0.description } - internal static var _intFormatter: (Int) -> String = { $0.description } - internal static var _doubleFormatter: (Double) -> String = { $0.description } - internal static var _nilFormatter: () -> String = { "" } - internal static var _voidFormatter: () -> String = { "" } - internal static var _stringFormatter: (String) -> String = { $0 } - internal static var _arrayFormatter: ([String]) -> String = + + internal static let _encoding = NIOLockedValueBox(.utf8) + internal static let _boolFormatter = NIOLockedValueBox<(Bool) -> String>({ $0.description }) + internal static let _intFormatter = NIOLockedValueBox<(Int) -> String>({ $0.description }) + internal static let _doubleFormatter = NIOLockedValueBox<(Double) -> String>({ $0.description }) + internal static let _nilFormatter = NIOLockedValueBox<(() -> String)>({ "" }) + internal static let _voidFormatter = NIOLockedValueBox<(() -> String)>({ "" }) + internal static let _stringFormatter = NIOLockedValueBox<((String) -> String)>({ $0 }) + internal static let _arrayFormatter = NIOLockedValueBox<(([String]) -> String)>( { "[\($0.map {"\"\($0)\""}.joined(separator: ", "))]" } - internal static var _dictFormatter: ([String: String]) -> String = + ) + internal static let _dictFormatter = NIOLockedValueBox<(([String: String]) -> String)>( { "[\($0.map { "\($0): \"\($1)\"" }.joined(separator: ", "))]" } - internal static var _dataFormatter: (Data) -> String? = - { String(data: $0, encoding: Self._encoding) } - + ) + internal static let _dataFormatter = NIOLockedValueBox<((Data) -> String?)>( + { String(data: $0, encoding: Self._encoding.withLockedValue { $0 }) } + ) /// Convenience flag for global write-once - private static var started = false + private static let started = NIOLockedValueBox(false) private static var running: Bool { - assert(!Self.started, "LeafKit can only be configured prior to instantiating any LeafRenderer") - return Self.started + assert(!Self.started.withLockedValue { $0 }, "LeafKit can only be configured prior to instantiating any LeafRenderer") + return Self.started.withLockedValue { $0 } } /// Convenience flag for local lock-after-access diff --git a/Sources/LeafKit/LeafData/LeafData.swift b/Sources/LeafKit/LeafData/LeafData.swift index 60500826..f26b7e28 100644 --- a/Sources/LeafKit/LeafData/LeafData.swift +++ b/Sources/LeafKit/LeafData/LeafData.swift @@ -15,10 +15,11 @@ public struct LeafData: CustomStringConvertible, ExpressibleByBooleanLiteral, ExpressibleByArrayLiteral, ExpressibleByFloatLiteral, - ExpressibleByNilLiteral { - + ExpressibleByNilLiteral, + Sendable { + /// The concrete instantiable object types for a `LeafData` - public enum NaturalType: String, CaseIterable, Hashable { + public enum NaturalType: String, CaseIterable, Hashable, Sendable { case bool case string case int @@ -250,7 +251,7 @@ public struct LeafData: CustomStringConvertible, /// Creates a new `LeafData` from `() -> LeafData` if possible or `nil` if not possible. /// `returns` must specify a `NaturalType` that the function will return - internal static func lazy(_ lambda: @escaping () -> LeafData, + internal static func lazy(_ lambda: @Sendable @escaping () -> LeafData, returns type: LeafData.NaturalType, invariant sideEffects: Bool) throws -> LeafData { LeafData(.lazy(f: lambda, returns: type, invariant: sideEffects)) diff --git a/Sources/LeafKit/LeafData/LeafDataStorage.swift b/Sources/LeafKit/LeafData/LeafDataStorage.swift index 6d7f9637..6a3e09bc 100644 --- a/Sources/LeafKit/LeafData/LeafDataStorage.swift +++ b/Sources/LeafKit/LeafData/LeafDataStorage.swift @@ -1,7 +1,7 @@ import NIO import Foundation -internal indirect enum LeafDataStorage: Equatable, CustomStringConvertible { +internal indirect enum LeafDataStorage: Equatable, CustomStringConvertible, Sendable { // MARK: - Cases // Static values @@ -20,7 +20,8 @@ internal indirect enum LeafDataStorage: Equatable, CustomStringConvertible { // Lazy resolvable function // Must specify return tuple giving (returnType, invariance) - case lazy(f: () -> (LeafData), + @preconcurrency + case lazy(f: @Sendable () -> (LeafData), returns: LeafData.NaturalType, invariant: Bool) diff --git a/Sources/LeafKit/LeafError.swift b/Sources/LeafKit/LeafError.swift index a03f0f81..43c08402 100644 --- a/Sources/LeafKit/LeafError.swift +++ b/Sources/LeafKit/LeafError.swift @@ -5,7 +5,7 @@ /// public struct LeafError: Error { /// Possible cases of a LeafError.Reason, with applicable stored values where useful for the type - public enum Reason { + public enum Reason: Sendable { // MARK: Errors related to loading raw templates /// Attempted to access a template blocked for security reasons case illegalAccess(String) @@ -109,7 +109,7 @@ public struct LeafError: Error { public struct LexerError: Error { // MARK: - Public - public enum Reason { + public enum Reason: Sendable { // MARK: Errors occuring during Lexing /// A character not usable in parameters is present when Lexer is not expecting it case invalidParameterToken(Character) diff --git a/Sources/LeafKit/LeafLexer/LeafLexer.swift b/Sources/LeafKit/LeafLexer/LeafLexer.swift index 206209a0..bcf7c514 100644 --- a/Sources/LeafKit/LeafLexer/LeafLexer.swift +++ b/Sources/LeafKit/LeafLexer/LeafLexer.swift @@ -63,7 +63,7 @@ internal struct LeafLexer { private mutating func nextToken() throws -> LeafToken? { // if EOF, return nil - no more to read guard let current = src.peek() else { return nil } - let isTagID = current == .tagIndicator + let isTagID = current == .tagIndicator.withLockedValue { $0 } let isTagVal = current.isValidInTagName let isCol = current == .colon let next = src.peek(aheadBy: 1) @@ -107,10 +107,11 @@ internal struct LeafLexer { /// Consume all data until hitting an unescaped `tagIndicator` and return a `.raw` token private mutating func lexRaw() -> LeafToken { var slice = "" - while let current = src.peek(), current != .tagIndicator { - slice += src.readWhile { $0 != .tagIndicator && $0 != .backSlash } + let tagIndicator = Character.tagIndicator.withLockedValue({ $0 }) + while let current = src.peek(), current != tagIndicator { + slice += src.readWhile { $0 != tagIndicator && $0 != .backSlash } guard let newCurrent = src.peek(), newCurrent == .backSlash else { break } - if let next = src.peek(aheadBy: 1), next == .tagIndicator { + if let next = src.peek(aheadBy: 1), next == tagIndicator { src.pop() } slice += src.pop()!.description @@ -129,7 +130,7 @@ internal struct LeafLexer { return .tagIndicator } else { state = .raw - return .raw(Character.tagIndicator.description) + return .raw((Character.tagIndicator.withLockedValue { $0 }).description) } } diff --git a/Sources/LeafKit/LeafLexer/LeafParameterTypes.swift b/Sources/LeafKit/LeafLexer/LeafParameterTypes.swift index 5a44f5e0..ab055492 100644 --- a/Sources/LeafKit/LeafLexer/LeafParameterTypes.swift +++ b/Sources/LeafKit/LeafLexer/LeafParameterTypes.swift @@ -1,7 +1,7 @@ // MARK: - `Parameter` Token Type /// An associated value enum holding data, objects or values usable as parameters to a `.tag` -public enum Parameter: Equatable, CustomStringConvertible { +public enum Parameter: Equatable, CustomStringConvertible, Sendable { case stringLiteral(String) case constant(Constant) case variable(name: String) @@ -44,7 +44,7 @@ public enum Parameter: Equatable, CustomStringConvertible { /// `Keyword`s are identifiers which take precedence over syntax/variable names - may potentially have /// representable state themselves as value when used with operators (eg, `true`, `false` when /// used with logical operators, `nil` when used with equality operators, and so forth) -public enum LeafKeyword: String, Equatable { +public enum LeafKeyword: String, Equatable, Sendable { // MARK: Public - Cases // Eval -> Bool / Other @@ -79,7 +79,7 @@ extension LeafKeyword { // MARK: - Operator Symbols /// Mathematical and Logical operators -public enum LeafOperator: String, Equatable, CustomStringConvertible, CaseIterable { +public enum LeafOperator: String, Equatable, CustomStringConvertible, CaseIterable, Sendable { // MARK: Public - Cases // Operator types: Logic Exist. UnPre Scope @@ -157,7 +157,7 @@ public enum LeafOperator: String, Equatable, CustomStringConvertible, CaseIterab } /// An integer or double constant value parameter (eg `1_000`, `-42.0`) -public enum Constant: CustomStringConvertible, Equatable { +public enum Constant: CustomStringConvertible, Equatable, Sendable { case int(Int) case double(Double) diff --git a/Sources/LeafKit/LeafLexer/LeafToken.swift b/Sources/LeafKit/LeafLexer/LeafToken.swift index c4cab323..1b3ab101 100644 --- a/Sources/LeafKit/LeafLexer/LeafToken.swift +++ b/Sources/LeafKit/LeafLexer/LeafToken.swift @@ -22,7 +22,7 @@ /// - `.whitespace`: Only generated when not at top-level, and unclear why maintaining it is useful /// -internal enum LeafToken: CustomStringConvertible, Equatable { +internal enum LeafToken: CustomStringConvertible, Equatable, Sendable { /// Holds a variable-length string of data that will be passed through with no processing case raw(String) diff --git a/Sources/LeafKit/LeafParser/LeafParameter.swift b/Sources/LeafKit/LeafParser/LeafParameter.swift index 9edd7470..84be13c2 100644 --- a/Sources/LeafKit/LeafParser/LeafParameter.swift +++ b/Sources/LeafKit/LeafParser/LeafParameter.swift @@ -1,6 +1,6 @@ import NIO -public indirect enum ParameterDeclaration: CustomStringConvertible { +public indirect enum ParameterDeclaration: CustomStringConvertible, Sendable { case parameter(Parameter) case expression([ParameterDeclaration]) case tag(Syntax.CustomTagDeclaration) diff --git a/Sources/LeafKit/LeafRenderer.swift b/Sources/LeafKit/LeafRenderer.swift index 2a1c6dc6..eff52a01 100644 --- a/Sources/LeafKit/LeafRenderer.swift +++ b/Sources/LeafKit/LeafRenderer.swift @@ -1,4 +1,5 @@ import NIO +import NIOConcurrencyHelpers // MARK: - `LeafRenderer` Summary @@ -10,7 +11,7 @@ import NIO /// /// Additional instances of LeafRenderer can then be created using these shared modules to allow /// concurrent rendering, potentially with unique per-instance scoped data via `userInfo`. -public final class LeafRenderer { +public final class LeafRenderer: Sendable { // MARK: - Public Only /// An initialized `LeafConfiguration` specificying default directory and tagIndicator @@ -25,9 +26,12 @@ public final class LeafRenderer { public let sources: LeafSources /// The NIO `EventLoop` on which this instance of `LeafRenderer` will operate public let eventLoop: any EventLoop + let _userInfo: NIOLoopBound<[AnyHashable: Any]> /// Any custom instance data to use (eg, in Vapor, the `Application` and/or `Request` data) - public let userInfo: [AnyHashable: Any] - + public var userInfo: [AnyHashable: Any] { + _userInfo.value + } + /// Initial configuration of LeafRenderer. public init( configuration: LeafConfiguration, @@ -42,7 +46,7 @@ public final class LeafRenderer { self.cache = cache self.sources = sources self.eventLoop = eventLoop - self.userInfo = userInfo + self._userInfo = .init(userInfo, eventLoop: eventLoop) } /// The public interface to `LeafRenderer` @@ -164,6 +168,7 @@ public final class LeafRenderer { let fetchRequests = ast.unresolvedRefs.map { self.fetch(template: $0, chain: chain) } + let constantChain = chain let results = EventLoopFuture.whenAllComplete(fetchRequests, on: self.eventLoop) return results.flatMap { results in let results = results @@ -182,7 +187,7 @@ public final class LeafRenderer { // Check new AST's unresolved refs to see if extension introduced new refs if !new.unresolvedRefs.subtracting(ast.unresolvedRefs).isEmpty { // AST has new references - try to resolve again recursively - return self.resolve(ast: new, chain: chain) + return self.resolve(ast: new, chain: constantChain) } else { // Cache extended AST & return - AST is either flat or unresolvable return self.cache.insert(new, on: self.eventLoop, replace: true) diff --git a/Sources/LeafKit/LeafSource/LeafSource.swift b/Sources/LeafKit/LeafSource/LeafSource.swift index 29baaa9a..90811306 100644 --- a/Sources/LeafKit/LeafSource/LeafSource.swift +++ b/Sources/LeafKit/LeafSource/LeafSource.swift @@ -1,7 +1,7 @@ import NIO /// Public protocol to adhere to in order to provide template source originators to `LeafRenderer` -public protocol LeafSource { +public protocol LeafSource: Sendable { /// Given a path name, return an EventLoopFuture holding a ByteBuffer /// - Parameters: /// - template: Relative template name (eg: `"path/to/template"`) diff --git a/Sources/LeafKit/LeafSource/LeafSources.swift b/Sources/LeafKit/LeafSource/LeafSources.swift index 3f2dfcdf..405540b1 100644 --- a/Sources/LeafKit/LeafSource/LeafSources.swift +++ b/Sources/LeafKit/LeafSource/LeafSources.swift @@ -11,7 +11,9 @@ import NIOConcurrencyHelpers /// prior to use by `LeafRenderer`. /// - `.all` provides a `Set` of the `String`keys for all sources registered with the instance /// - `.searchOrder` provides the keys of sources that an unspecified template request will search. -public final class LeafSources { +/// +/// `@unchecked Sendable` because uses locks to guarantee Sendability. +public final class LeafSources: @unchecked Sendable { // MARK: - Public /// All available `LeafSource`s of templates @@ -53,8 +55,8 @@ public final class LeafSources { internal private(set) var sources: [String: any LeafSource] private var order: [String] private let lock: NIOLock = .init() - - /// Locate a template from the sources; if a specific source is named, only try to read from it. Otherwise, use the specified search order + + /// Locate a template from the sources; if a specific source is named, only try to read from it. Otherwise, use the specified search order internal func find(template: String, in source: String? = nil, on eventLoop: any EventLoop) throws -> EventLoopFuture<(String, ByteBuffer)> { var keys: [String] @@ -71,12 +73,11 @@ public final class LeafSources { private func searchSources(t: String, on eL: any EventLoop, s: [String]) -> EventLoopFuture<(String, ByteBuffer)> { guard !s.isEmpty else { return eL.makeFailedFuture(LeafError(.noTemplateExists(t))) } - var more = s - let key = more.removeFirst() - lock.lock() - let source = sources[key]! - lock.unlock() - + var _more = s + let key = _more.removeFirst() + let source = self.lock.withLock { sources[key]! } + let more = _more + do { let file = try source.file(template: t, escape: true, on: eL) // Hit the file - return the combined tuple diff --git a/Sources/LeafKit/LeafSource/NIOLeafFiles.swift b/Sources/LeafKit/LeafSource/NIOLeafFiles.swift index 36a8558b..ea3278f1 100644 --- a/Sources/LeafKit/LeafSource/NIOLeafFiles.swift +++ b/Sources/LeafKit/LeafSource/NIOLeafFiles.swift @@ -5,7 +5,7 @@ import NIO /// file reader for `LeafRenderer` /// /// Default initializer will -public struct NIOLeafFiles: LeafSource { +public struct NIOLeafFiles: LeafSource, Sendable { // MARK: - Public /// Various options for configuring an instance of `NIOLeafFiles` @@ -17,7 +17,7 @@ public struct NIOLeafFiles: LeafSource { /// inside a directory starting with `.`) /// /// A new `NIOLeafFiles` defaults to [.toSandbox, .toVisibleFiles, .requireExtensions] - public struct Limit: OptionSet { + public struct Limit: OptionSet, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue diff --git a/Sources/LeafKit/LeafSyntax/LeafSyntax.swift b/Sources/LeafKit/LeafSyntax/LeafSyntax.swift index 2ab7de22..b0e82eae 100644 --- a/Sources/LeafKit/LeafSyntax/LeafSyntax.swift +++ b/Sources/LeafKit/LeafSyntax/LeafSyntax.swift @@ -1,6 +1,6 @@ import NIO -public indirect enum Syntax { +public indirect enum Syntax: Sendable { // MARK: .raw - Makeable, Entirely Readable case raw(ByteBuffer) // MARK: `case variable(Variable)` removed @@ -23,7 +23,7 @@ public indirect enum Syntax { case export(Export) } -public enum ConditionalSyntax { +public enum ConditionalSyntax: Sendable { case `if`([ParameterDeclaration]) case `elseif`([ParameterDeclaration]) case `else` @@ -172,7 +172,7 @@ func indent(_ depth: Int) -> String { } extension Syntax { - public struct Import { + public struct Import: Sendable { public let key: String public init(_ params: [ParameterDeclaration]) throws { guard params.count == 1 else { throw "import only supports single param \(params)" } @@ -186,7 +186,7 @@ extension Syntax { } } - public struct Extend: BodiedSyntax { + public struct Extend: BodiedSyntax, Sendable { public let key: String public private(set) var exports: [String: Export] public private(set) var context: [ParameterDeclaration]? @@ -313,7 +313,7 @@ extension Syntax { } } - public struct Export: BodiedSyntax { + public struct Export: BodiedSyntax, Sendable { public let key: String public internal(set) var body: [Syntax] private var externalsSet: Set @@ -369,7 +369,7 @@ extension Syntax { } } - public struct Conditional: BodiedSyntax { + public struct Conditional: BodiedSyntax, Sendable { public internal(set) var chain: [( condition: ConditionalSyntax, body: [Syntax] @@ -470,7 +470,7 @@ extension Syntax { } } - public struct With: BodiedSyntax { + public struct With: BodiedSyntax, Sendable { public internal(set) var body: [Syntax] public internal(set) var context: [ParameterDeclaration] @@ -534,7 +534,7 @@ extension Syntax { } } - public struct Loop: BodiedSyntax { + public struct Loop: BodiedSyntax, Sendable { /// the key to use when accessing items public let item: String /// the key to use to access the array @@ -620,7 +620,7 @@ extension Syntax { } } - public struct CustomTagDeclaration: BodiedSyntax { + public struct CustomTagDeclaration: BodiedSyntax, Sendable { public let name: String public let params: [ParameterDeclaration] public internal(set) var body: [Syntax]? diff --git a/Sources/LeafKit/LeafSyntax/LeafTag.swift b/Sources/LeafKit/LeafSyntax/LeafTag.swift index 82a86c2c..29740e65 100644 --- a/Sources/LeafKit/LeafSyntax/LeafTag.swift +++ b/Sources/LeafKit/LeafSyntax/LeafTag.swift @@ -1,14 +1,16 @@ +import NIOConcurrencyHelpers import Foundation /// Create custom tags by conforming to this protocol and registering them. -public protocol LeafTag { +@preconcurrency +public protocol LeafTag: Sendable { func render(_ ctx: LeafContext) throws -> LeafData } /// Tags conforming to this protocol do not get their contents HTML-escaped. public protocol UnsafeUnescapedLeafTag: LeafTag {} -public var defaultTags: [String: any LeafTag] = [ +let _defaultTags = NIOLockedValueBox<[String: LeafTag]>([ "unsafeHTML": UnsafeHTML(), "lowercased": Lowercased(), "uppercased": Uppercased(), @@ -19,7 +21,16 @@ public var defaultTags: [String: any LeafTag] = [ "count": Count(), "comment": Comment(), "dumpContext": DumpContext() -] +]) + +public var defaultTags: [String: LeafTag] { + get { + _defaultTags.withLockedValue { $0 } + } + set(newValue) { + _defaultTags.withLockedValue { $0 = newValue } + } +} struct UnsafeHTML: UnsafeUnescapedLeafTag { func render(_ ctx: LeafContext) throws -> LeafData { diff --git a/Tests/LeafKitTests/GHTests/VaporLeafKit.swift b/Tests/LeafKitTests/GHTests/VaporLeafKit.swift index 50b0d498..405c44df 100644 --- a/Tests/LeafKitTests/GHTests/VaporLeafKit.swift +++ b/Tests/LeafKitTests/GHTests/VaporLeafKit.swift @@ -118,7 +118,7 @@ final class GHLeafKitIssuesTest: XCTestCase { XCTAssertEqual(page.string, expected) // Page rendering throws expected error - let config = LeafConfiguration(rootDirectory: "/", tagIndicator: Character.tagIndicator, ignoreUnfoundImports: false) + let config = LeafConfiguration(rootDirectory: "/", tagIndicator: Character.tagIndicator.withLockedValue { $0 }, ignoreUnfoundImports: false) XCTAssertThrowsError(try TestRenderer(configuration: config, sources: .singleSource(test)).render(path: "page").wait()) { error in XCTAssertEqual("\(error)", "import(\"body\") should have been resolved BEFORE serialization") } diff --git a/Tests/LeafKitTests/TestHelpers.swift b/Tests/LeafKitTests/TestHelpers.swift index 8e4b4718..bef50462 100644 --- a/Tests/LeafKitTests/TestHelpers.swift +++ b/Tests/LeafKitTests/TestHelpers.swift @@ -47,7 +47,9 @@ internal func render(name: String = "test-render", _ template: String, _ context // MARK: - Helper Structs and Classes /// Helper wrapping` LeafRenderer` to preconfigure for simplicity & allow eliding context -internal class TestRenderer { +/// +/// `@unchecked Sendable` because uses locks to guarantee Sendability. +internal class TestRenderer: @unchecked Sendable { var r: LeafRenderer private let lock: NIOLock private var counter: Int = 0 @@ -89,7 +91,7 @@ internal class TestRenderer { internal struct TestFiles: LeafSource { var files: [String: String] var lock: NIOLock - + init() { files = [:] lock = .init()