From 262cf68f65e2174ea27f1f0f4ca135b8aad43f7a Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Tue, 21 Apr 2026 13:04:05 -0500 Subject: [PATCH 1/2] feature: Add InMemoryModuleReader for evaluating virtual Pkl modules from a Swift dictionary --- Sources/PklSwift/InMemoryModuleReader.swift | 156 ++++++++++++++++++++ Tests/PklSwiftTests/EvaluatorTest.swift | 109 ++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 Sources/PklSwift/InMemoryModuleReader.swift diff --git a/Sources/PklSwift/InMemoryModuleReader.swift b/Sources/PklSwift/InMemoryModuleReader.swift new file mode 100644 index 0000000..7274599 --- /dev/null +++ b/Sources/PklSwift/InMemoryModuleReader.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 + +/// A ``ModuleReader`` backed by an in-memory dictionary of URI strings → Pkl source text. +/// +/// Use this to evaluate virtual 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 = InMemoryModuleReader([ +/// "mem:base.pkl": "abstract module Base\nhost: String", +/// "mem: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" +/// } +/// ``` +/// +/// ## URI Format +/// +/// Keys in the dictionary must be fully-qualified URIs whose scheme matches the reader's +/// ``scheme`` property (default: `"mem"`). For hierarchical URIs the path should begin +/// with a forward slash, e.g. `"mem:/catalog/birds.pkl"`. +/// Non-hierarchical URIs need no slash, 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 modules. +/// ``listElements(uri:)`` returns the *direct* children of the given base URI, +/// sorted alphabetically for deterministic output. +public struct InMemoryModuleReader: ModuleReader { + // MARK: - Stored properties + + /// The URI-keyed dictionary of Pkl source strings this reader serves. + /// + /// Keys are fully-qualified URI strings (e.g. `"mem:config.pkl"` or + /// `"mem:/catalog/swallow.pkl"`). Values are the raw Pkl source text. + public let modules: [String: String] + + /// The URI scheme handled by this reader. + /// + /// Defaults to `"mem"`. Override if you need multiple independent readers + /// in the same evaluator, or if `"mem"` conflicts with another reader. + public let scheme: String + + // MARK: - Initializer + + /// Creates an ``InMemoryModuleReader`` from a dictionary of URI → Pkl source mappings. + /// + /// - Parameters: + /// - modules: A dictionary whose keys are fully-qualified URI strings and whose values + /// are the Pkl source text for that module. + /// - scheme: The URI scheme this reader claims. Defaults to `"mem"`. + public init(_ modules: [String: String], scheme: String = "mem") { + self.modules = modules + self.scheme = scheme + } + + // MARK: - ModuleReader conformance + + /// `true` — enables `import*(...)` glob syntax against registered modules. + public var isGlobbable: Bool { true } + + /// `true` — hierarchical path components are meaningful (enables triple-dot imports). + 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`. + /// + /// - Throws: ``InMemoryModuleReaderError/moduleNotFound(_:)`` when no module is + /// registered under `url.absoluteString`. + public func read(url: URL) async throws -> String { + let key = url.absoluteString + guard let source = modules[key] else { + throw InMemoryModuleReaderError.moduleNotFound(key) + } + return source + } + + /// Returns the direct children of `uri` among all registered module keys. + /// + /// The implementation performs a single linear scan of ``modules``, strips the + /// common `uri` 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 base = uri.absoluteString + var seen = Set() + var result: [PathElement] = [] + + for key in modules.keys { + // Only consider keys that are strictly beneath the base URI. + guard key.hasPrefix(base), key != base else { continue } + + let remainder = String(key.dropFirst(base.count)) + // Strip a leading "/" so that both "mem:/dir/f.pkl" and "mem:dir/f.pkl" 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 ``InMemoryModuleReader``. +public enum InMemoryModuleReaderError: Error, CustomStringConvertible { + /// The reader has no module registered under the requested URI. + case moduleNotFound(String) + + public var description: String { + switch self { + case .moduleNotFound(let uri): + return "InMemoryModuleReader: no module registered for URI \"\(uri)\"" + } + } +} diff --git a/Tests/PklSwiftTests/EvaluatorTest.swift b/Tests/PklSwiftTests/EvaluatorTest.swift index 042ae6c..8fda5ff 100644 --- a/Tests/PklSwiftTests/EvaluatorTest.swift +++ b/Tests/PklSwiftTests/EvaluatorTest.swift @@ -475,5 +475,114 @@ final class PklSwiftTests: XCTestCase { // """, output) // } // } + + // MARK: - InMemoryModuleReader tests + + /// A single virtual module is read and evaluated successfully. + func testInMemoryModuleReaderBasicRead() async throws { + let reader = InMemoryModuleReader([ + "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 module resolves the base through the same reader. + func testInMemoryModuleReaderMultiFileAmends() async throws { + let reader = InMemoryModuleReader([ + "mem:base.pkl": "abstract module Base\nhost: String", + "mem: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 modules, sorted deterministically. + func testInMemoryModuleReaderGlobImport() async throws { + let reader = InMemoryModuleReader([ + "mem:/birds/swallow.pkl": "name = \"Swallow\"\nnumberOfEggs = 8", + "mem:/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 URI not present in the dictionary throws `InMemoryModuleReaderError.moduleNotFound`. + func testInMemoryModuleReaderModuleNotFound() async throws { + let reader = InMemoryModuleReader([:]) + 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("mem:missing.pkl"), + "Error message should mention the missing URI; got: \(error)" + ) + } + try await evaluator.close() + } + + /// A reader initialised with a non-default scheme is registered and evaluated correctly. + func testInMemoryModuleReaderCustomScheme() async throws { + let reader = InMemoryModuleReader( + ["virtual:config.pkl": "answer = 42"], + scheme: "virtual" + ) + 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 testInMemoryModuleReaderListElementsDirectChildrenOnly() async throws { + let reader = InMemoryModuleReader([ + "mem:/a/b/deep.pkl": "x = 1", + "mem:/a/shallow.pkl": "y = 2", + "mem:/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 From 8d8433ccd213b938453fa5695f6d92e440c4416c Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 26 Apr 2026 15:59:35 -0500 Subject: [PATCH 2/2] refactor: rename to VirtualFileReader and move to PklSwiftContrib target --- Package.swift | 17 ++ Sources/PklSwift/InMemoryModuleReader.swift | 156 --------------- .../PklSwiftContrib/VirtualFileReader.swift | 182 ++++++++++++++++++ .../VirtualFileReaderTests.swift | 156 +++++++++++++++ Tests/PklSwiftTests/EvaluatorTest.swift | 107 ---------- 5 files changed, 355 insertions(+), 263 deletions(-) delete mode 100644 Sources/PklSwift/InMemoryModuleReader.swift create mode 100644 Sources/PklSwiftContrib/VirtualFileReader.swift create mode 100644 Tests/PklSwiftContribTests/VirtualFileReaderTests.swift 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/PklSwift/InMemoryModuleReader.swift b/Sources/PklSwift/InMemoryModuleReader.swift deleted file mode 100644 index 7274599..0000000 --- a/Sources/PklSwift/InMemoryModuleReader.swift +++ /dev/null @@ -1,156 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 - -/// A ``ModuleReader`` backed by an in-memory dictionary of URI strings → Pkl source text. -/// -/// Use this to evaluate virtual 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 = InMemoryModuleReader([ -/// "mem:base.pkl": "abstract module Base\nhost: String", -/// "mem: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" -/// } -/// ``` -/// -/// ## URI Format -/// -/// Keys in the dictionary must be fully-qualified URIs whose scheme matches the reader's -/// ``scheme`` property (default: `"mem"`). For hierarchical URIs the path should begin -/// with a forward slash, e.g. `"mem:/catalog/birds.pkl"`. -/// Non-hierarchical URIs need no slash, 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 modules. -/// ``listElements(uri:)`` returns the *direct* children of the given base URI, -/// sorted alphabetically for deterministic output. -public struct InMemoryModuleReader: ModuleReader { - // MARK: - Stored properties - - /// The URI-keyed dictionary of Pkl source strings this reader serves. - /// - /// Keys are fully-qualified URI strings (e.g. `"mem:config.pkl"` or - /// `"mem:/catalog/swallow.pkl"`). Values are the raw Pkl source text. - public let modules: [String: String] - - /// The URI scheme handled by this reader. - /// - /// Defaults to `"mem"`. Override if you need multiple independent readers - /// in the same evaluator, or if `"mem"` conflicts with another reader. - public let scheme: String - - // MARK: - Initializer - - /// Creates an ``InMemoryModuleReader`` from a dictionary of URI → Pkl source mappings. - /// - /// - Parameters: - /// - modules: A dictionary whose keys are fully-qualified URI strings and whose values - /// are the Pkl source text for that module. - /// - scheme: The URI scheme this reader claims. Defaults to `"mem"`. - public init(_ modules: [String: String], scheme: String = "mem") { - self.modules = modules - self.scheme = scheme - } - - // MARK: - ModuleReader conformance - - /// `true` — enables `import*(...)` glob syntax against registered modules. - public var isGlobbable: Bool { true } - - /// `true` — hierarchical path components are meaningful (enables triple-dot imports). - 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`. - /// - /// - Throws: ``InMemoryModuleReaderError/moduleNotFound(_:)`` when no module is - /// registered under `url.absoluteString`. - public func read(url: URL) async throws -> String { - let key = url.absoluteString - guard let source = modules[key] else { - throw InMemoryModuleReaderError.moduleNotFound(key) - } - return source - } - - /// Returns the direct children of `uri` among all registered module keys. - /// - /// The implementation performs a single linear scan of ``modules``, strips the - /// common `uri` 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 base = uri.absoluteString - var seen = Set() - var result: [PathElement] = [] - - for key in modules.keys { - // Only consider keys that are strictly beneath the base URI. - guard key.hasPrefix(base), key != base else { continue } - - let remainder = String(key.dropFirst(base.count)) - // Strip a leading "/" so that both "mem:/dir/f.pkl" and "mem:dir/f.pkl" 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 ``InMemoryModuleReader``. -public enum InMemoryModuleReaderError: Error, CustomStringConvertible { - /// The reader has no module registered under the requested URI. - case moduleNotFound(String) - - public var description: String { - switch self { - case .moduleNotFound(let uri): - return "InMemoryModuleReader: no module registered for URI \"\(uri)\"" - } - } -} 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 8fda5ff..5e308aa 100644 --- a/Tests/PklSwiftTests/EvaluatorTest.swift +++ b/Tests/PklSwiftTests/EvaluatorTest.swift @@ -476,113 +476,6 @@ final class PklSwiftTests: XCTestCase { // } // } - // MARK: - InMemoryModuleReader tests - /// A single virtual module is read and evaluated successfully. - func testInMemoryModuleReaderBasicRead() async throws { - let reader = InMemoryModuleReader([ - "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 module resolves the base through the same reader. - func testInMemoryModuleReaderMultiFileAmends() async throws { - let reader = InMemoryModuleReader([ - "mem:base.pkl": "abstract module Base\nhost: String", - "mem: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 modules, sorted deterministically. - func testInMemoryModuleReaderGlobImport() async throws { - let reader = InMemoryModuleReader([ - "mem:/birds/swallow.pkl": "name = \"Swallow\"\nnumberOfEggs = 8", - "mem:/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 URI not present in the dictionary throws `InMemoryModuleReaderError.moduleNotFound`. - func testInMemoryModuleReaderModuleNotFound() async throws { - let reader = InMemoryModuleReader([:]) - 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("mem:missing.pkl"), - "Error message should mention the missing URI; got: \(error)" - ) - } - try await evaluator.close() - } - - /// A reader initialised with a non-default scheme is registered and evaluated correctly. - func testInMemoryModuleReaderCustomScheme() async throws { - let reader = InMemoryModuleReader( - ["virtual:config.pkl": "answer = 42"], - scheme: "virtual" - ) - 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 testInMemoryModuleReaderListElementsDirectChildrenOnly() async throws { - let reader = InMemoryModuleReader([ - "mem:/a/b/deep.pkl": "x = 1", - "mem:/a/shallow.pkl": "y = 2", - "mem:/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