diff --git a/Package.swift b/Package.swift index d860eda..ac2fbca 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,10 @@ let package = Package( name: "PklSwift", targets: ["PklSwift"] ), + .library( + name: "PklSwiftContrib", + targets: ["PklSwiftContrib"] + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-system", from: "1.2.1"), @@ -50,6 +54,11 @@ let package = Package( dependencies: ["MessagePack", "PklSwiftInternals", "SemanticVersion"], swiftSettings: [.enableUpcomingFeature("StrictConcurrency")], ), + .target( + name: "PklSwiftContrib", + dependencies: ["PklSwift"], + swiftSettings: [.enableUpcomingFeature("StrictConcurrency")] + ), .target( name: "PklSwiftInternals", swiftSettings: [.enableUpcomingFeature("StrictConcurrency")] @@ -87,6 +96,14 @@ let package = Package( ], swiftSettings: [.enableUpcomingFeature("StrictConcurrency")] ), + .testTarget( + name: "PklSwiftContribTests", + dependencies: [ + "PklSwift", + "PklSwiftContrib", + ], + swiftSettings: [.enableUpcomingFeature("StrictConcurrency")] + ), .testTarget( name: "MessagePackTests", dependencies: [ diff --git a/Sources/PklSwiftContrib/VirtualFileReader.swift b/Sources/PklSwiftContrib/VirtualFileReader.swift new file mode 100644 index 0000000..e99a29b --- /dev/null +++ b/Sources/PklSwiftContrib/VirtualFileReader.swift @@ -0,0 +1,182 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import PklSwift + +/// A ``ModuleReader`` that serves Pkl modules from an in-memory virtual filesystem. +/// +/// `VirtualFileReader` models a hierarchical filesystem: all paths must begin with `/` +/// and path separators (`/`) denote directory structure, just like `file:` or `http:` URIs. +/// This makes it suitable for evaluating multi-file Pkl configurations without touching disk. +/// +/// It is particularly useful for: +/// - **Unit testing**: supply known Pkl text without writing temporary files. +/// - **Dynamic configuration**: assemble Pkl modules from runtime data and evaluate them. +/// - **Multi-file setups**: model `amends`/`import` relationships across several virtual modules. +/// +/// ## Example +/// +/// ```swift +/// let reader = try VirtualFileReader("mem", [ +/// "/base.pkl": "abstract module Base\nhost: String", +/// "/config.pkl": #"amends "mem:/base.pkl"\nhost = "localhost""#, +/// ]) +/// let options = EvaluatorOptions.preconfigured.withModuleReader(reader) +/// try await withEvaluator(options: options) { evaluator in +/// let output = try await evaluator.evaluateOutputText( +/// source: .uri("mem:/config.pkl")! +/// ) +/// print(output) // host = "localhost" +/// } +/// ``` +/// +/// ## Path Format +/// +/// Keys in the `files` dictionary must be absolute paths beginning with a forward slash, +/// e.g. `"/config.pkl"` or `"/catalog/birds.pkl"`. The initializer throws +/// ``VirtualFileReaderError/invalidPath(_:)`` if any key does not start with `/`. +/// +/// When evaluating, reference modules using a URI whose scheme matches the reader's +/// ``scheme`` property, e.g. `"mem:/config.pkl"`. +/// +/// ## Glob and Triple-Dot Imports +/// +/// Because ``isGlobbable`` and ``isLocal`` are both `true`, Pkl's `import*(...)` and +/// triple-dot (`...`) import syntax both work against the registered files. +/// ``listElements(uri:)`` returns the *direct* children of the given base URI, +/// sorted alphabetically for deterministic output. +public struct VirtualFileReader: ModuleReader { + // MARK: - Stored properties + + /// The virtual filesystem: a dictionary mapping absolute paths to Pkl source text. + /// + /// Keys are absolute paths beginning with `/` (e.g. `"/config.pkl"` or + /// `"/catalog/swallow.pkl"`). Values are the raw Pkl source text. + public let files: [String: String] + + /// The URI scheme handled by this reader. + public let scheme: String + + // MARK: - Initializer + + /// Creates a ``VirtualFileReader`` with the given scheme and file mappings. + /// + /// - Parameters: + /// - scheme: The URI scheme this reader claims (e.g. `"mem"`). + /// - files: A dictionary whose keys are absolute paths beginning with `/`, and whose + /// values are the Pkl source text for that module. + /// - Throws: ``VirtualFileReaderError/invalidPath(_:)`` if any key does not start with `/`. + public init(_ scheme: String, _ files: [String: String]) throws { + for key in files.keys { + guard key.hasPrefix("/") else { + throw VirtualFileReaderError.invalidPath(key) + } + } + self.scheme = scheme + self.files = files + } + + // MARK: - ModuleReader conformance + + /// `true` — enables `import*(...)` glob syntax against registered files. + public var isGlobbable: Bool { true } + + /// `true` — path separators (`/`) denote directory structure (hierarchical URIs). + public var hasHierarchicalUris: Bool { true } + + /// `true` — Pkl treats these modules as local, enabling triple-dot (`...`) resolution. + public var isLocal: Bool { true } + + /// Returns the Pkl source text for the module at `url`. + /// + /// The path is extracted by stripping the `:` prefix from the URL's absolute string. + /// + /// - Throws: ``VirtualFileReaderError/fileNotFound(_:)`` when no file is registered + /// under the resolved path. + public func read(url: URL) async throws -> String { + let urlString = url.absoluteString + guard urlString.hasPrefix(scheme + ":") else { + throw VirtualFileReaderError.fileNotFound(urlString) + } + + let path = String(urlString.dropFirst(scheme.count + 1)) + + guard let source = files[path] else { + throw VirtualFileReaderError.fileNotFound(path) + } + return source + } + + /// Returns the direct children of `uri` among all registered file paths. + /// + /// The implementation performs a single linear scan of ``files``, strips the + /// common base-path prefix, and groups results by their first remaining path component. + /// Directory entries (intermediate path segments) are synthesised automatically. + /// Output is sorted by name for deterministic glob results. + public func listElements(uri: URL) async throws -> [PathElement] { + let urlString = uri.absoluteString + guard urlString.hasPrefix(scheme + ":") else { return [] } + + let basePath = String(urlString.dropFirst(scheme.count + 1)) + + var seen = Set() + var result: [PathElement] = [] + + for key in files.keys { + // Only consider keys that are strictly beneath the base path. + guard key.hasPrefix(basePath), key != basePath else { continue } + + let remainder = String(key.dropFirst(basePath.count)) + // Strip a leading "/" so that both "/dir/" and "/dir" base paths work. + let trimmed = remainder.hasPrefix("/") ? String(remainder.dropFirst()) : remainder + guard !trimmed.isEmpty else { continue } + + // Split on the first "/" to get only the immediate child. + let components = trimmed.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true) + guard let first = components.first.map(String.init) else { continue } + + if seen.insert(first).inserted { + // If there is more than one component, this child is a directory. + let isDirectory = components.count > 1 + result.append(PathElement(name: first, isDirectory: isDirectory)) + } + } + + // Sort for deterministic output (important for snapshot / glob tests). + return result.sorted { $0.name < $1.name } + } +} + +// MARK: - Error type + +/// Errors thrown by ``VirtualFileReader``. +public enum VirtualFileReaderError: Error, CustomStringConvertible { + /// A file path passed to the initializer does not start with `/`. + case invalidPath(String) + + /// The reader has no file registered under the requested path. + case fileNotFound(String) + + public var description: String { + switch self { + case .invalidPath(let path): + return "VirtualFileReader: file path must start with '/', but got: \"\(path)\"" + case .fileNotFound(let path): + return "VirtualFileReader: no file registered for path \"\(path)\"" + } + } +} diff --git a/Tests/PklSwiftContribTests/VirtualFileReaderTests.swift b/Tests/PklSwiftContribTests/VirtualFileReaderTests.swift new file mode 100644 index 0000000..c27e699 --- /dev/null +++ b/Tests/PklSwiftContribTests/VirtualFileReaderTests.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import XCTest +import PklSwift +@testable import PklSwiftContrib + +#if os(macOS) || os(Linux) || os(Windows) +final class VirtualFileReaderTests: XCTestCase { + var manager: EvaluatorManager! + + override func setUp() { + self.manager = EvaluatorManager() + } + + override func tearDown() async throws { + await self.manager.close() + } + + // MARK: - Initializer validation + + /// The initializer throws when a key does not start with `/`. + func testInitThrowsOnInvalidPath() { + XCTAssertThrowsError(try VirtualFileReader("mem", [ + "config.pkl": "x = 1", + ])) { error in + XCTAssertTrue( + "\(error)".contains("config.pkl"), + "Error should mention the invalid path; got: \(error)" + ) + } + } + + // MARK: - Basic evaluation + + /// A single virtual file is read and evaluated successfully. + func testBasicRead() async throws { + let reader = try VirtualFileReader("mem", [ + "/config.pkl": "host = \"localhost\"\nport = 8080", + ]) + let options = EvaluatorOptions.preconfigured.withModuleReader(reader) + let evaluator = try await manager.newEvaluator(options: options) + let output = try await evaluator.evaluateOutputText(source: .uri("mem:/config.pkl")!) + XCTAssertEqual(output, "host = \"localhost\"\nport = 8080\n") + try await evaluator.close() + } + + /// A module that amends another virtual file resolves the base through the same reader. + func testMultiFileAmends() async throws { + let reader = try VirtualFileReader("mem", [ + "/base.pkl": "abstract module Base\nhost: String", + "/config.pkl": #"amends "mem:/base.pkl"\nhost = "localhost""#, + ]) + let options = EvaluatorOptions.preconfigured.withModuleReader(reader) + let evaluator = try await manager.newEvaluator(options: options) + let output = try await evaluator.evaluateOutputText(source: .uri("mem:/config.pkl")!) + XCTAssertEqual(output, "host = \"localhost\"\n") + try await evaluator.close() + } + + /// `import*(...)` glob resolves to all matching virtual files, sorted deterministically. + func testGlobImport() async throws { + let reader = try VirtualFileReader("mem", [ + "/birds/swallow.pkl": "name = \"Swallow\"\nnumberOfEggs = 8", + "/birds/penguin.pkl": "name = \"Penguin\"\nnumberOfEggs = 1", + ]) + let options = EvaluatorOptions.preconfigured.withModuleReader(reader) + let evaluator = try await manager.newEvaluator(options: options) + let result = try await evaluator.evaluateOutputText(source: .text(""" + result = import*("mem:/birds/*.pkl") + """)) + XCTAssertEqual(result, """ + result { + ["mem:/birds/penguin.pkl"] { + name = "Penguin" + numberOfEggs = 1 + } + ["mem:/birds/swallow.pkl"] { + name = "Swallow" + numberOfEggs = 8 + } + } + + """) + try await evaluator.close() + } + + /// Requesting a path not present in the dictionary throws `VirtualFileReaderError.fileNotFound`. + func testFileNotFound() async throws { + let reader = try VirtualFileReader("mem", [:]) + let options = EvaluatorOptions.preconfigured.withModuleReader(reader) + let evaluator = try await manager.newEvaluator(options: options) + do { + _ = try await evaluator.evaluateOutputText(source: .uri("mem:/missing.pkl")!) + XCTFail("Expected an error to be thrown") + } catch { + XCTAssertTrue( + "\(error)".contains("/missing.pkl"), + "Error message should mention the missing path; got: \(error)" + ) + } + try await evaluator.close() + } + + /// A reader initialised with a non-default scheme is registered and evaluated correctly. + func testCustomScheme() async throws { + let reader = try VirtualFileReader("virtual", [ + "/config.pkl": "answer = 42", + ]) + let options = EvaluatorOptions.preconfigured.withModuleReader(reader) + let evaluator = try await manager.newEvaluator(options: options) + let output = try await evaluator.evaluateOutputText(source: .uri("virtual:/config.pkl")!) + XCTAssertEqual(output, "answer = 42\n") + try await evaluator.close() + } + + /// `listElements` returns only *direct* children of the requested URI, + /// not grandchildren, so that glob patterns resolve one level at a time. + func testListElementsDirectChildrenOnly() async throws { + let reader = try VirtualFileReader("mem", [ + "/a/b/deep.pkl": "x = 1", + "/a/shallow.pkl": "y = 2", + "/other.pkl": "z = 3", + ]) + // List the direct children of "mem:/a/" + let base = URL(string: "mem:/a/")! + let elements = try await reader.listElements(uri: base) + + // Expect exactly two children: "b" (directory) and "shallow.pkl" (file) + XCTAssertEqual(elements.count, 2) + let names = elements.map(\.name) + XCTAssertTrue(names.contains("b"), "Expected directory 'b'; got \(names)") + XCTAssertTrue(names.contains("shallow.pkl"), "Expected file 'shallow.pkl'; got \(names)") + + let bEntry = elements.first { $0.name == "b" }! + XCTAssertTrue(bEntry.isDirectory, "'b' should be reported as a directory") + + let shallowEntry = elements.first { $0.name == "shallow.pkl" }! + XCTAssertFalse(shallowEntry.isDirectory, "'shallow.pkl' should not be reported as a directory") + } +} +#endif diff --git a/Tests/PklSwiftTests/EvaluatorTest.swift b/Tests/PklSwiftTests/EvaluatorTest.swift index 042ae6c..5e308aa 100644 --- a/Tests/PklSwiftTests/EvaluatorTest.swift +++ b/Tests/PklSwiftTests/EvaluatorTest.swift @@ -475,5 +475,7 @@ final class PklSwiftTests: XCTestCase { // """, output) // } // } + + } #endif