diff --git a/.github/workflows/SyntaxKit.yml b/.github/workflows/SyntaxKit.yml index 1363b825..ca966bde 100644 --- a/.github/workflows/SyntaxKit.yml +++ b/.github/workflows/SyntaxKit.yml @@ -65,7 +65,7 @@ jobs: run: | if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.0"},{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" else echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" @@ -79,15 +79,12 @@ jobs: runs-on: ubuntu-latest container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} strategy: + fail-fast: false matrix: os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} exclude: - - swift: {version: "6.0"} - type: wasm - - swift: {version: "6.0"} - type: wasm-embedded - swift: {version: "6.1"} type: wasm - swift: {version: "6.1"} @@ -298,7 +295,7 @@ jobs: swift: - version: "6.2" - version: "6.3" - android-api-level: [33, 34, 35, 36] + android-api-level: [33, 34, 35] steps: - uses: actions/checkout@v6 - name: Free disk space diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml index a8c05a03..4ab0b691 100644 --- a/.github/workflows/swift-source-compat.yml +++ b/.github/workflows/swift-source-compat.yml @@ -16,7 +16,6 @@ jobs: fail-fast: false matrix: container: - - swift:6.0 - swift:6.1 - swift:6.2 - swift:6.3 diff --git a/.gitignore b/.gitignore index 1e2c5c40..1cee5599 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ playground.xcworkspace .swiftpm .build/ +.build-linux/ # CocoaPods # We recommend against adding the Pods directory to your .gitignore. However diff --git a/.swift-version b/.swift-version deleted file mode 100644 index a435f5a5..00000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -6.1 diff --git a/.vscode/launch.json b/.vscode/launch.json index fefdbc03..1c2682d4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,26 @@ "preLaunchTask": "swift: Build Release skit", "target": "skit", "configuration": "release" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:SyntaxKit}", + "name": "Debug skitrun", + "target": "skitrun", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug skitrun" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:SyntaxKit}", + "name": "Release skitrun", + "target": "skitrun", + "configuration": "release", + "preLaunchTask": "swift: Build Release skitrun" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index a09a578d..57cf92e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,16 @@ Sources/SyntaxKit/ 4. Run `./Scripts/lint.sh` to ensure code quality 5. Run `swift test` to verify functionality +### Coding Conventions +- **No global functions or variables.** Every function and `let`/`var` must be + nested inside a type (`struct`/`enum`/`class`) or an `extension`. This includes + declarations that look indented only because they sit inside a `#if … #endif` + conditional-compilation block (e.g. `Sources/skit/Runner.swift`) — those are + still file-scope globals and must be moved into a type. This is not currently + enforced by SwiftLint: a `custom_rules` regex can't reliably distinguish a + global nested in `#if` from a legitimate type member, so it needs an + AST-aware (SwiftSyntax) check to enforce. Until then, follow the rule by hand. + ### Package Dependencies - **SwiftSyntax** (601.0.1+) - Apple's Swift syntax parser - **SwiftOperators** - Operator handling @@ -93,8 +103,8 @@ Sources/SyntaxKit/ 2. **skit Executable** - Command-line tool for parsing Swift code to JSON ### Platform Support -- Swift 6.0+ required -- Xcode 16.0+ for development +- Swift 6.1+ required +- Xcode 16.3+ for development ### Testing - Uses modern Swift Testing framework (`@Test` syntax) diff --git a/Docs/research/tuist-manifest-pipeline.md b/Docs/research/tuist-manifest-pipeline.md new file mode 100644 index 00000000..313464b8 --- /dev/null +++ b/Docs/research/tuist-manifest-pipeline.md @@ -0,0 +1,265 @@ +# How Tuist Evaluates Manifests + +> Phase 1 deliverable for [issue #154](https://github.com/brightdigit/SyntaxKit/issues/154). +> Source: read of `tuist/tuist@main` and `swiftlang/swift-package-manager@main` via GitHub. Every behavioral claim cites a file/line in those repos. + +## TL;DR + +- **Manifest is run, not pre-compiled to an artifact.** Tuist invokes `/usr/bin/xcrun swift ` in Swift script/interpreter mode with `-I/-L/-F` pointing at `ProjectDescription.framework` (`cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:374-528`). There is no separate `.o`/executable for the manifest itself. +- **JSON over stdout, token-delimited.** The manifest's `Project(...)` initializer calls `dumpIfNeeded(self)` (`cli/Sources/ProjectDescription/Dump.swift:3-14`) which `JSONEncoder`s `self` and prints it bracketed by `TUIST_MANIFEST_START` / `TUIST_MANIFEST_END`; the host scans stdout for those tokens and `JSONDecoder`s the slice between them (`ManifestLoader.swift:119-120, 347-365`). +- **Helpers are real dylibs.** Files under `/Tuist/ProjectDescriptionHelpers/` are compiled by a separate `swiftc -emit-module -emit-library -parse-as-library` invocation into `lib.dylib`, cached on disk, and added to the manifest's `swift` invocation via `-I/-L/-F/-l` (`cli/Sources/TuistLoader/ProjectDescriptionHelpers/ProjectDescriptionHelpersBuilder.swift:174-298`). +- **Two-layer cache.** The helpers module is keyed by an md5 of (file SHA-256s + Tuist version + Swift toolchain version + macOS version + macOS SDK version + tuist env vars + DEBUG flag) (`ProjectDescriptionHelpersHasher.swift:34-55`). The decoded manifest JSON itself is cached in `~/.tuist/Cache/Manifests`, keyed by (manifest SHA-256 + helpers hash + plugins hash + env hash + sandbox flag + Tuist version + cache schema version) (`CachedManifestLoader.swift:181-275`). +- **Sandboxed.** On macOS the whole `xcrun swift …` command is wrapped in `sandbox-exec -p ` by default; the profile denies everything and re-allows only read access to the project path, Xcode, the `ProjectDescription` search paths, and `/private/tmp` + `/private/var` for writes (`ManifestLoader.swift:492-574`). + +## 1. Compilation pipeline + +The manifest is **not** compiled by Tuist to a separate artifact — it is run with `swift` in interpreter mode. Argument construction lives in `ManifestLoader.buildArguments(_:at:disableSandbox:)`: + +```swift +// cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:392-401 +var arguments = [ + "/usr/bin/xcrun", + "swift", + "-suppress-warnings", + "-I", searchPaths.includeSearchPath.pathString, + "-L", searchPaths.librarySearchPath.pathString, + "-F", searchPaths.frameworkSearchPath.pathString, + "-l\(frameworkName)", + "-framework", frameworkName, +] +``` + +Then helpers are stitched in (`ManifestLoader.swift:423-428`): + +```swift +let projectDescriptionHelperArguments = projectDescriptionHelperModules.flatMap { [ + "-I", $0.path.parentDirectory.pathString, + "-L", $0.path.parentDirectory.pathString, + "-F", $0.path.parentDirectory.pathString, + "-l\($0.name)", +] } +``` + +The manifest path itself is appended last (`ManifestLoader.swift:478`), and `--tuist-dump` is added by the caller (`ManifestLoader.swift:344`). There is **no** `-target`, `-sdk`, `-module-name`, `-emit-executable`, or `-emit-library` on the manifest run — those are SwiftPM-style flags that Tuist deliberately avoids for the manifest. The implicit target/SDK come from `xcrun swift` itself (i.e. from the active Xcode selected via `xcode-select`). + +**For `Package.swift` (the `.packageSettings` case)**, Tuist additionally points `-I/-L/-F` at `XcodeDefault.xctoolchain/usr/lib/swift/pm/ManifestAPI`, links `-lPackageDescription`, and passes `-package-description-version -D TUIST` and a JSON-encoded `-context ` argument that SwiftPM's `PackageDescription` runtime expects (`ManifestLoader.swift:430-490`). + +**Helpers compilation** uses `swiftc` directly (`ProjectDescriptionHelpersBuilder.swift:264-298`): + +```swift +var command: [String] = [ + "/usr/bin/xcrun", "swiftc", + "-module-name", moduleName, + "-emit-module", + "-emit-module-path", outputDirectory.appending(component: "\(moduleName).swiftmodule").pathString, + "-parse-as-library", + "-emit-library", + "-suppress-warnings", + "-I", projectDescriptionSearchPaths.includeSearchPath.pathString, + "-L", projectDescriptionSearchPaths.librarySearchPath.pathString, + "-F", projectDescriptionSearchPaths.frameworkSearchPath.pathString, + "-working-directory", outputDirectory.pathString, +] +// + helper-module flags + `-framework ProjectDescription` (or `-lProjectDescription` when dylib) + all *.swift sources +``` + +The helper artifact is a dylib named `lib.dylib` (`ProjectDescriptionHelpersBuilder.swift:188-190`). + +## 2. Execution model + +It's model **(b) — `Process`-spawn + parse stdout**. There is no `dlopen`, no exported C symbol, no plugin entry point. `loadDataForManifest` simply runs the constructed `arguments` via `CommandRunner.capture(...)`: + +```swift +// cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:347-365 +let string = try await commandRunner.capture( + arguments: arguments, + environment: Environment.current.manifestLoadingVariables +) + +guard let startTokenRange = string.range(of: ManifestLoader.startManifestToken, options: .literal), + let endTokenRange = string.range(of: ManifestLoader.endManifestToken, options: [.literal, .backwards]) +else { + return string.data(using: .utf8)! +} +// ... slice out the JSON ... +let manifest = string[startTokenRange.upperBound ..< endTokenRange.lowerBound] +return manifest.data(using: .utf8)! +``` + +The host scans the entire captured stdout for `TUIST_MANIFEST_START` and `TUIST_MANIFEST_END` (`ManifestLoader.swift:119-120`). Anything *before* the start token and *after* the end token is treated as user-visible log output and re-emitted via `Logger.current.notice(...)` (`ManifestLoader.swift:358-362`). This means a manifest can `print(…)` debug output *and* still be parsed correctly — a useful side-effect of the token-delimited design. + +The slice between the tokens is utf8-decoded and passed straight to `JSONDecoder().decode(T.self, from: data)` (`ManifestLoader.swift:256-257`). + +## 3. Bridging format + +JSON, generated by `JSONEncoder` on the manifest side and `JSONDecoder` on the host side. + +**Encode site** (manifest process): `cli/Sources/ProjectDescription/Dump.swift:3-14`: + +```swift +func dumpIfNeeded(_ entity: some Encodable) { + guard !ProcessInfo.processInfo.arguments.isEmpty, + ProcessInfo.processInfo.arguments.contains("--tuist-dump") + else { return } + let encoder = JSONEncoder() + let data = try! encoder.encode(entity) + let manifest = String(data: data, encoding: .utf8)! + print("TUIST_MANIFEST_START") + print(manifest) + print("TUIST_MANIFEST_END") +} +``` + +`dumpIfNeeded(self)` is invoked from inside the *initializer* of each top-level manifest type. For example, `Project.init(...)` ends with `dumpIfNeeded(self)` (`cli/Sources/ProjectDescription/Project.swift:109`). This is what makes a bare `let _ = Project(...)` self-publishing — the user never has to call anything; constructing the value at the top level *is* the side-effect. + +**Decode site** (host process): `cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:127, 175, 257`: + +```swift +private let decoder: JSONDecoder +… +return try decoder.decode(T.self, from: data) +``` + +All top-level `ProjectDescription` types (`Project`, `Workspace`, `Config`, `Template`, `Plugin`, `PackageSettings`) are `Codable` — that's what allows the same `Decodable` constraint on `loadManifest(...)` (`ManifestLoader.swift:244`) to handle every manifest kind. + +Note the asymmetry: the manifest side imports `ProjectDescription` and produces `ProjectDescription.Project`; the host side decodes into the **same** `ProjectDescription.Project` Swift type (`ManifestLoading.loadProject` returns `ProjectDescription.Project`), and only later does `ManifestModelConverter` / `Project+ManifestMapper.swift` map that into `XcodeGraph.Project` (the internal model). So `ProjectDescription` plays a dual role: API surface for users + DTO/IR for the bridge. + +## 4. ProjectDescriptionHelpers + +The flow is: locate → hash → compile-or-reuse → splice flags into the manifest `swift` command. + +**Locate** (`HelpersDirectoryLocator.swift:37-44`): + +```swift +public func locate(at: AbsolutePath) async throws -> AbsolutePath? { + guard let rootDirectory = try await rootDirectoryLocator.locate(from: at) else { return nil } + let helpersDirectory = rootDirectory + .appending(component: Constants.tuistDirectoryName) // "Tuist" + .appending(component: Constants.helpersDirectoryName) // "ProjectDescriptionHelpers" + if try await !fileSystem.exists(helpersDirectory) { return nil } + return helpersDirectory +} +``` + +So the discovery rule is literally: walk up from the manifest's directory to the project root, then look for `Tuist/ProjectDescriptionHelpers/`. If absent, helpers are skipped. + +**Build** (`ProjectDescriptionHelpersBuilder.swift:174-255`): each helpers directory is hashed (see section 5), the hash becomes a subdirectory name under the on-disk cache, and the build is gated on whether that directory already exists. If not, files are globbed (`**/*.swift`), `swiftc` runs into a sibling **staging directory** (`.tmp..`) and is then atomically `rename(2)`'d into place (`ProjectDescriptionHelpersBuilder.swift:204-244`) — explicitly designed to be safe under concurrent Tuist processes. + +**Plugin helpers** (`ProjectDescriptionHelpersBuilder.swift:132-144`) get built first because local helpers may import plugin helpers but not vice-versa. The plugin helper modules are then passed in as `customProjectDescriptionHelperModules` to the local helpers compile, so the local helpers can `import `. + +**Splicing into the manifest invocation** (`ManifestLoader.swift:405-428`): for `.project`, `.template`, `.workspace`, `.packageSettings` manifests, the helpers builder is called; for `.config`, `.plugin`, `.package`, helpers are *not* built (so you can't `import ProjectDescriptionHelpers` from `Tuist.swift`/`Plugin.swift` — the loader logs a clear error if you try, see `logUnexpectedImportErrorIfNeeded`, `ManifestLoader.swift:576-592`). + +In-process the builder also memoizes via `builtHelpers: ThreadSafe<[AbsolutePath: Task<…>]>` (`ProjectDescriptionHelpersBuilder.swift:69, 180-252`), so within one Tuist invocation a helpers directory is compiled at most once even if many manifests are loaded. + +## 5. Caching + +There are **two** caches; both contribute to the "manifest didn't change → skip work" property. + +**Helpers cache** (`ProjectDescriptionHelpersHasher.swift:34-55`): + +```swift +let fileHashes = try await fileSystem + .glob(directory: helpersDirectory, include: ["**/*.swift"]) + .collect() + .sorted() + .compactMap { $0.sha256() } + .compactMap { $0.compactMap { byte in String(format: "%02x", byte) }.joined() } +let tuistEnvVariables = Environment.current.manifestLoadingVariables.map { "\($0.key)=\($0.value)" }.sorted() +let swiftlangVersion = try await SwiftVersionProvider.current.swiftlangVersion() +let macosVersion = machineEnvironment.macOSVersion +let macosSDKVersion = try await MacOSSDKVersionProvider.current.macOSSDKVersion() +… +let identifiers = + [macosVersion, macosSDKVersion, swiftlangVersion, tuistVersion] + fileHashes + tuistEnvVariables + ["\(debug)"] +return identifiers.joined(separator: "-").md5 +``` + +So the helpers cache key is sensitive to: per-file SHA-256s (sorted), macOS version, macOS SDK version, Swift compiler/toolchain version, Tuist version, every `TUIST_*` env var, and DEBUG vs release Tuist build. The on-disk location is `cacheDirectoriesProvider.cacheDirectory(for: .projectDescriptionHelpers)` (`ManifestLoader.swift:402-403`), with the hash as the directory name — defaults under `~/.cache/tuist/…` on macOS / Linux per Tuist's CacheDirectoriesProvider conventions. + +**Decoded-manifest cache** (`CachedManifestLoader.swift:181-275`): after the manifest has been compiled-and-run once, the resulting JSON-encoded `ProjectDescription.Project` value is itself cached at `cacheDirectoriesProvider.cacheDirectory(for: .manifests)` (default `~/.tuist/Cache/Manifests` per the class doc, `CachedManifestLoader.swift:15`), keyed by: + +```swift +// CachedManifestLoader.swift:192-198 +return Hashes( + manifestHash: manifestHash, // SHA-256 of Project.swift + helpersHash: helpersHash, // md5 from ProjectDescriptionHelpersHasher + pluginsHash: try await pluginsHashCache.value?.value, + environmentHash: environmentHash, // md5 of TUIST_* env vars + disableSandboxHash: disableSandboxHash +) +``` + +Plus a `cacheVersion` integer (`CachedManifest.currentCacheVersion = 1`, `CachedManifestLoader.swift:314`) and the Tuist version (`CachedManifestLoader.swift:268-273`) — all five must match for a cache hit. On a hit, Tuist skips spawning `swift` entirely and decodes the cached JSON directly. + +## 6. Toolchain resolution + +Two paths. + +**For `swift` / `swiftc`**: Tuist hard-codes `/usr/bin/xcrun` (`ManifestLoader.swift:393`, `ProjectDescriptionHelpersBuilder.swift:267`) and lets xcrun resolve the toolchain. So the active Xcode (set by `xcode-select -s` or by `DEVELOPER_DIR`) determines `swift`/`swiftc`. Tuist does not appear to honor a `TOOLCHAINS=` or a custom `.xctoolchain` indirectly beyond whatever xcrun itself does. + +**For the SDK / Xcode path** (only needed for `.packageSettings`): `ManifestLoader.swift:432-457`: + +```swift +let xcodePath = try await { + if let developerDir = Environment.current.variables["DEVELOPER_DIR"] { + let developerDirPath = try AbsolutePath(validating: developerDir) + let resolvedXcodePath = if developerDirPath.components.suffix(2) == ["Contents", "Developer"] { + developerDirPath.parentDirectory.parentDirectory + } else { + developerDirPath + } + let manifestPath = resolvedXcodePath + .appending(components: "Contents", "Developer", "Toolchains", + "XcodeDefault.xctoolchain", "usr", "lib", "swift", "pm", "ManifestAPI") + if try await fileSystem.exists(manifestPath) { + return resolvedXcodePath + } + } + return try await XcodeController.current.selected().path +}() +``` + +So `DEVELOPER_DIR` is consulted first (with normalization for either form — pointing at `Xcode.app` or at `Xcode.app/Contents/Developer`), falling back to whatever `xcode-select` reports. + +**For `ProjectDescription.framework` / `libProjectDescription.dylib`**: `ResourceLocator.frameworkPath` (`cli/Sources/TuistLoader/Utils/ResourceLocator.swift:52-83`) searches relative to the Tuist binary's `Bundle.bundleURL`, including a Homebrew-style `bin/` ↔ `lib/` adjacency, and honors `TUIST_FRAMEWORK_SEARCH_PATHS` (space-separated). It will accept any of `libProjectDescription.dylib`, `ProjectDescription.framework`, or `PackageFrameworks/ProjectDescription.framework` — and `ProjectDescriptionSearchPaths.pathStyle(for:)` (`cli/Sources/TuistLoader/Utils/ProjectDescriptionPaths.swift:85-93`) decides whether downstream include/library/framework search paths should be derived dylib-style or framework-style. + +## 7. Failure modes + +- **Syntax error / compile failure**: `swift` exits non-zero, `CommandRunner` throws `CommandError.terminated(exitCode, standardError, command)`, and the host wraps it. Two hooks (`logUnexpectedImportErrorIfNeeded`, `logPluginHelperBuildErrorIfNeeded`, `ManifestLoader.swift:576-604`) inspect stderr and surface friendlier errors for common cases (e.g. importing `ProjectDescriptionHelpers` from `Config.swift`/`Plugin.swift`, or a missing plugin helper module). +- **Runtime crash / non-zero exit**: same path — `CommandRunner.capture(...)` throws, and the loader bubbles it up. +- **No start/end tokens in stdout**: the loader does **not** error; it falls back to treating the entire stdout as the JSON payload (`ManifestLoader.swift:352-356`). The JSON decode will then fail and produce a `ManifestLoaderError.manifestLoadingFailed` with the captured payload included for debugging (`ManifestLoader.swift:259-265`). +- **Decode failure**: each `DecodingError` case (`typeMismatch`, `valueNotFound`, `keyNotFound`, `dataCorrupted`) is mapped to a tailored `manifestLoadingFailed(context:)` error message that includes the offending JSON (`ManifestLoader.swift:267-315`). +- **Missing import / unresolved module**: stderr from `swift` carries the message; the two `logXxxErrorIfNeeded` functions detect the most common culprit (default helpers from a forbidden manifest, or a plugin helper that failed its own build) and emit a targeted log line. Beyond that the raw compile error reaches the user. +- **Hang / timeout**: no process-wait timeout in `ManifestLoader` or `CommandRunner` usage. Tuist appears to wait indefinitely for the `swift` subprocess to finish. (Inferred — `grep` for `timeout|terminate|sleep` in the loader files surfaced nothing.) +- **Empty `Package.swift`** is special-cased: `loadPackageSettings` catches `manifestLoadingFailed` with `data.count == 0` and returns a default `PackageSettings()` (`ManifestLoader.swift:217-230`). +- **Sandbox**: by default on macOS, the `swift` call is wrapped in `sandbox-exec -p ` (`ManifestLoader.swift:516-521`). The profile (`ManifestLoader.swift:547-574`) denies by default, then allows `process*`, `file-read-metadata`, RW under `/private/tmp` and `/private/var`, and read-only access to: the project path, the selected Xcode path, `com.apple.dt.Xcode.plist`, the three ProjectDescription search paths, the resolved `DEVELOPER_DIR`, and every helpers-module parent directory. Manifests can opt out via `disableSandbox: true` (config and plugin always run unsandboxed: `ManifestLoader.swift:195, 207, 234`). + +## 8. SwiftPM comparison + +SwiftPM uses model **(b)** as well but goes one extra step: it actually compiles the manifest into a **temporary executable on disk** and then `Process`-spawns *that* (`Sources/PackageLoading/ManifestLoader.swift:779-908` in `swiftlang/swift-package-manager`). In `evaluateManifest(...)`: + +```swift +// swift-package-manager/Sources/PackageLoading/ManifestLoader.swift:779-786 +let compiledManifestFile = tmpDir.appending("\(packageIdentity)-manifest\(executableSuffix)") +cmd += ["-o", compiledManifestFile.pathString] +``` + +then later (`:840, :906-908`): + +```swift +var runCmd = [compiledManifestFile.pathString] +… +runResult = try await AsyncProcess.popen(arguments: runCmd, environment: environment) +``` + +The framework search path for `PackageDescription` comes from `self.toolchain.swiftPMLibrariesLocation.manifestLibraryPath` and is added with `-F -Xlinker -rpath -Xlinker -framework PackageDescription` (or the `-L/-lPackageDescription/-rpath` dylib variant), plus an `-target` derived from `swiftPMLibrariesLocation.manifestLibraryMinimumDeploymentTarget` (`Sources/PackageLoading/ManifestLoader.swift:721-755`). Tuist by contrast (a) skips the explicit `-target` (lets `swift` interpreter pick its default), (b) does not write an intermediate executable to disk, (c) does not use `-Xlinker -rpath` for the manifest, and (d) wraps the whole thing in `sandbox-exec`. The net effect is the same — JSON on stdout, host decodes — but Tuist's path is one process and one disk-write fewer per manifest load (offset by the heavier helpers cache). + +The framework-search-path mechanics are the most directly transferable to a SyntaxKit equivalent: both tools resolve a *bundled, versioned* library that ships next to the CLI (Tuist's `ResourceLocator`, SwiftPM's `swiftPMLibrariesLocation`) and pass it via `-I/-L/-F + -framework ` (or `-l` for dylib distributions) to a single Swift compiler invocation that *also* takes the user's manifest path as a positional argument. + +## Open questions / things I couldn't determine + +- **Exact `CommandRunner.capture` semantics.** Call sites read but not implementation; *assuming* it returns stdout — the token-scanning code clearly operates on stdout-style content, but it isn't verified whether `print(…)` logs from user code (stdout) and compiler errors (stderr) end up in the same string. Inferred from `logUnexpectedImportErrorIfNeeded`'s use of `CommandError.terminated(_, standardError, command)` (`ManifestLoader.swift:577`) that stderr is captured separately. +- **Timeout.** No `terminate(after:)` / `timeout:` wiring around the manifest-spawn. If the user manifest infinite-loops, Tuist appears to wait forever. Not 100% certain because the `Command` package is a separate dependency not opened here. +- **Linux behavior.** The sandbox branch is `#if os(macOS)`; on Linux the manifest runs un-sandboxed (`ManifestLoader.swift:522-524`). Did not check whether `ResourceLocator` finds the framework on Linux or only the dylib — the `ProjectDescriptionSearchPaths.Style.commandLine` branch (which derives `-I` from a sibling `Modules/` directory) suggests Linux relies on `libProjectDescription.dylib + Modules/ProjectDescription.swiftmodule`. +- **Module cache.** SwiftPM passes `-module-cache-path` (`swift-package-manager Sources/PackageLoading/ManifestLoader.swift:759-761`); Tuist does **not** set one for the manifest invocation, so the manifest run inherits the default global Swift module cache. Significance for SyntaxKit's use case not chased. +- **`Config.swift` / `Tuist.swift` special path.** Both fall through `loadConfig` → `loadManifest(.config, …, disableSandbox: true)` → same `swift` invocation, but skip helpers entirely (`ManifestLoader.swift:407-408`). Not verified whether `Tuist.swift` accepts a different framework name (the switch in `buildArguments` lumps `.config` under `frameworkName = "ProjectDescription"`). diff --git a/Docs/skit.md b/Docs/skit.md new file mode 100644 index 00000000..1832afcd --- /dev/null +++ b/Docs/skit.md @@ -0,0 +1,177 @@ +# `skit` — a SyntaxKit CLI for config-driven Swift codegen + +`skit` is a small CLI that takes a SyntaxKit DSL file as input and writes Swift source out the other side. The vision: pure data — JSON, YAML, your own format — drives a manifest written in the SyntaxKit DSL, and `skit` materializes it into idiomatic Swift you check in alongside everything else. Less hand-maintenance, fewer drift bugs. + +This doc walks through how `skit` is built. Two verbs, one wrap-and-spawn pipeline, an output cache, and a careful toolchain story underneath. Where there are sharp edges, this doc names them. + +## Two verbs + +``` +skit run Input.swift # SyntaxKit DSL → Swift source on stdout +skit run Input.swift -o Out.swift +skit run InputDir/ -o OutDir/ # walk **/*.swift and mirror rendered output +skit parse < Input.swift # Swift source → JSON syntax tree +``` + +`run` is the default subcommand. `skit Input.swift` is shorthand for `skit run Input.swift`. `parse` is a one-shot for the inverse direction (Swift source → SwiftSyntax tree as JSON) — useful for tooling that wants to introspect existing code. + +The remainder of this doc is about `run`, which is where the interesting design decisions live. + +## How `skit run` works + +A `.swift` input file looks like a SyntaxKit DSL expression at the top level: + +```swift +// Models.swift +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +``` + +The input is not a complete Swift program. It has no `@main`, no `print`, no `let root = …`. `skit run` adds the boilerplate by wrapping the input in a `Group { … }` builder and a top-level `print` that renders the result: + +```swift +// What `skit run` writes to a temp file before spawning `swift`: +import SyntaxKit + +let __skit_root = Group { +#sourceLocation(file: "/path/to/Models.swift", line: 3) +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +#sourceLocation() +} + +print(__skit_root.generateCode()) +``` + +`skit` then spawns `swift` (the script-mode interpreter, not `swiftc`) on this wrapped file, captures stdout, and writes the result to the user's chosen output target. + +Three things deserve a closer look: + +**Imports get hoisted.** The wrapper has to start with `import SyntaxKit` so the DSL types are available. Any additional `import`s the user writes need to live at the top of the file, not inside `Group { … }`. `skit` parses the input with SwiftSyntax, peels off the leading `import` declarations, and lifts them into the wrapper preamble. Anything else (declarations, expressions, top-level types) stays in the body. + +**`#sourceLocation` keeps diagnostics readable.** When the spawned `swift` emits a compile error, it reports a line number in the wrapped temp file, which is meaningless to the user. The `#sourceLocation` directive remaps body diagnostics back to the original input path and line. Errors in the wrapper preamble (the `import` block, the `Group { … }` opening) still reference the temp file — `skit` rewrites occurrences of the temp path in stderr to the input path as a fallback, so users see something coherent. + +**`swift` runs in script mode.** Running `swift Input.swift` invokes the Swift interpreter rather than going through `swiftc` + `ld`. Cold-start is around 700ms on macOS; warm spawns are around 110ms. The CLI's hot path leans into this — for batch input via `skit run InputDir/`, we spawn one `swift` per input file in parallel up to the active core count. + +## Output cache + +One layer, keyed by content hash. Lives under `~/Library/Caches/com.brightdigit.SyntaxKit/outputs//output.swift` on macOS, `$XDG_CACHE_HOME/syntaxkit/outputs//output.swift` (or `~/.cache/syntaxkit/outputs//output.swift`) on Linux. On hit, `skit` skips the `swift` spawn for an input entirely. + +The fully-rendered output of an input gets cached by a hash of (input bytes, libSyntaxKit stamp, swift version, sorted `SKIT_*`/`SYNTAXKIT_*` env vars). On hit, total wall time is around 0.14s, dominated by hash + file read. Cold miss matches the warm script-mode baseline (~0.5s). + +`--no-cache` skips the cache. + +## Toolchain stamping + +Pre-compiled Swift modules (`SyntaxKit.swiftmodule`) are tightly coupled to the compiler that built them. Even patch-level differences fail to load: + +``` +error: module compiled with Swift 6.3 cannot be imported by the Swift 6.3.2 compiler +``` + +If `skit`'s bundled `lib/SyntaxKit.swiftmodule` doesn't match the user's `swift`, the spawned interpreter emits exactly this diagnostic and refuses to compile the wrapped input. The user is left staring at a cryptic message that doesn't name the actual problem. + +`skit` mitigates this by recording the build toolchain at bundle time. `Scripts/build-skit.sh` writes `lib/swift-version.txt` containing the output of `swift --version`. On startup, `skit run` reads the stamp and compares it to a freshly-captured local `swift --version`. Three paths: + +- **Match.** Proceed silently. +- **Mismatch.** Print a clear error naming both versions and the rebuild command, exit 2. Skip with `--no-toolchain-check`. +- **Missing stamp.** Print a one-line note and proceed. Older bundles built before this check existed shouldn't break. + +The comparison is exact-string match. Patch-level drift broke the originating bug, so anything less strict would just defer the failure to the cryptic message we're trying to avoid. + +**What this doesn't do**: it doesn't fix the mismatch, just surfaces it. The fix is to re-run the release script with the user's current toolchain. The auto-rebuild path — bundle SyntaxKit sources alongside the prebuilt module and recompile transparently when the stamp doesn't match — is tracked as [issue #157](https://github.com/brightdigit/SyntaxKit/issues/157). + +## Timeout watchdog + +The spawned `swift` is the only unbounded piece of `skit run`'s hot path. The wrap step is microseconds. The output cache hits or misses in milliseconds. But the spawn itself runs *user code* — and that code is allowed to be arbitrarily slow, recursive, or stuck. + +`skit run` defaults to a 60s per-input timeout. On expiry it sends `SIGTERM`, gives a 5s grace, then `SIGKILL`. The wrapped input exits with code 124 — POSIX `timeout(1)`'s convention. `--timeout ` overrides the default; `--timeout 0` disables the watchdog entirely (useful for debugging genuinely long codegen). + +The implementation is `DispatchSemaphore.wait(timeout: deadline)` paired with a `process.terminationHandler` that signals on child exit. The Linux Foundation `Process.waitUntilExit()` hangs on already-exited children on some configurations, which is why `skit` uses the semaphore-based wait everywhere. Same story for pipe drains — sequential reads after the child exits can deadlock when either pipe (~64 KB buffer on Linux) fills before exit, so both pipes drain concurrently via `DispatchGroup`. + +## Sharp edges + +### `if`-in-`Group` crashes the Swift type-checker — [#158](https://github.com/brightdigit/SyntaxKit/issues/158) + +`CodeBlockBuilderResult` declares all the methods needed for `if`/`else` in a result builder (`buildEither`, `buildOptional`, `buildArray`), but using them surfaces a Swift type-checker bug: + +```swift +let _ = Group { + if true { // ← error: failed to produce diagnostic for expression + Struct("A") { … } + } +} +``` + +This is a Swift compiler bug, not a `skit` bug. The workaround is to hoist the conditional into a plain Swift function that uses **plain Swift `if`/`else`** (not a `Group { if … }` body) to return one of two `CodeBlock`s: + +```swift +func optionalDebugField(_ include: Bool) -> any CodeBlock { + if include { + return Variable(.let, name: "debug", type: "Bool") + } else { + return Group {} // empty Group as the "no-op" branch — there's no + // public EmptyCodeBlock type + } +} + +Struct("Config") { + Variable(.let, name: "name", type: "String") + optionalDebugField(buildIsDebug) +} +``` + +The helper function itself can't use `Group { if … }` either — same crash. Plain Swift control flow only. + +### `@main` and top-level decl attributes don't work + +`@main` and other decl attributes (like top-level `@available`) at the start of the input fail to compile. The wrapper places the user's body inside `Group { … }`, where these attributes try to bind to a function-call expression rather than a declaration. Examples: + +```swift +@main // ❌ error: expected declaration +Struct("Person") { … } + +@available(iOS 17, *) // ❌ error: expected declaration +Struct("ModernView") { … } +``` + +The DSL has its own mechanism for attribute attachment — call `.attribute("Published")`, `.attribute("available", arguments: ["iOS 17", "*"])` on the `Variable`/`Struct`/etc. — which renders the attribute in the *output*, not on the DSL expression itself. That's the path users should take. + +### Comments don't carry through + +Swift comments in the input file (`// MARK: - Models`, block comments, inline `//` after a DSL line) don't render to the output. The input's comments are *only* for the input's author. To emit a comment in the rendered code, attach it to a `CodeBlock` via `.comment { Line(.doc, "…") }`. + +### `#if os(...)` is gen-time, not output + +A `#if os(macOS) … #endif` block in the input is evaluated when the wrapped file compiles, controlling whether the enclosed DSL expressions are part of the builder. It does *not* emit a `#if os(macOS)` directive into the rendered Swift output. If you want compile-time-conditional output, emit the `#if` lines as raw text via `VariableExp("#if os(macOS)")` (or write a small helper). + +## Platform notes + +**macOS** is the primary target. The build and release flows live in `Scripts/`; the bundle is portable across machines with the same Swift version. + +**Linux** is verified on `swift:6.0-jammy/aarch64`. One adjustment compared to macOS: the Mach-O `install_name` rewrite in `Scripts/build-skit.sh` is skipped — GNU `ld` doesn't accept the flag. The `-rpath` injection (which is what actually locates the dylib at runtime) works on both platforms. + +The Foundation.Process workarounds described earlier are Linux-driven. `Process.waitUntilExit()` blocks indefinitely on already-exited children on `swift:6.0-jammy/aarch64` — `skit` uses `DispatchSemaphore` everywhere a child wait is needed, and drains stdout/stderr pipes concurrently to avoid deadlocks when either pipe buffer fills. + +**Windows** is not supported. + +## What's deferred + +A few things were considered for v1 and explicitly punted: + +- **Auto-rebuild on toolchain mismatch** — [#157](https://github.com/brightdigit/SyntaxKit/issues/157). Today the user gets a clear error and a rebuild command; tomorrow `skit` should rebuild `libSyntaxKit` from bundled sources transparently and cache the result per Swift version. The stamp-and-detect path shipped in this release is the foundation; the rebuild fallback is the natural next step. +- **Multi-file output from a single input** — out of scope. The folder mode handles N-inputs → N-outputs; if you need fan-out from one logical generator, split it into multiple input files. +- **Sandboxing the spawned `swift`** — out of scope. The threat model is "you ran your own code", same as `swift Input.swift` by hand. +- **HTTP / web-server form** — out of scope for the CLI. A long-lived server could share a warm interpreter and the caches across requests, but that's a different shape with its own isolation questions. Revisit later. + +## Reference + +- [`Sources/skit/README.md`](../Sources/skit/README.md) — per-target quick reference (flag table). +- [`Scripts/build-skit.sh`](../Scripts/build-skit.sh) — release-bundle builder (use `--debug` for the fast iteration variant). +- [`Docs/research/tuist-manifest-pipeline.md`](research/tuist-manifest-pipeline.md) — the manifest-pipeline pattern this CLI borrows from. +- [Issue #154](https://github.com/brightdigit/SyntaxKit/issues/154) — original tracking issue. +- [Issue #157](https://github.com/brightdigit/SyntaxKit/issues/157), [Issue #158](https://github.com/brightdigit/SyntaxKit/issues/158) — follow-ups. diff --git a/Examples/Completed/attributes/dsl.swift b/Examples/Completed/attributes/dsl.swift index daf5416f..e7d453a7 100644 --- a/Examples/Completed/attributes/dsl.swift +++ b/Examples/Completed/attributes/dsl.swift @@ -1,7 +1,10 @@ Class("Foo") { - Variable(.var, name: "bar", type: "String", defaultValue: "bar").attribute("Published") + Variable(.var, name: "bar", type: "String", equals: "bar").attribute("Published") Function("bar") { - print("bar") + Call("print") { + ParameterExp(unlabeled: Literal.string("bar")) + } }.attribute("available", arguments: ["iOS 17.0", "*"]) Function("baz") { -}.attribute("objc")}.attribute("objc") \ No newline at end of file + }.attribute("objc") +} diff --git a/Examples/Completed/blackjack/dsl.swift b/Examples/Completed/blackjack/dsl.swift index d0af68d4..f894b515 100644 --- a/Examples/Completed/blackjack/dsl.swift +++ b/Examples/Completed/blackjack/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Example of generating a BlackjackCard struct with a nested Suit enum -let structExample = Struct("BlackjackCard") { +Struct("BlackjackCard") { Enum("Suit") { EnumCase("spades").equals("♠") EnumCase("hearts").equals("♡") @@ -28,29 +28,29 @@ let structExample = Struct("BlackjackCard") { Variable(.let, name: "first", type: "Int") Variable(.let, name: "second", type: "Int?") } - ComputedProperty("values") { + ComputedProperty("values", type: "Values") { Switch("self") { SwitchCase(".ace") { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "1") - Parameter(name: "second", value: "11") + ParameterExp(name: "first", value: "1") + ParameterExp(name: "second", value: "11") } } } SwitchCase(".jack", ".queen", ".king") { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "10") - Parameter(name: "second", value: "nil") + ParameterExp(name: "first", value: "10") + ParameterExp(name: "second", value: "nil") } } } Default { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "self.rawValue") - Parameter(name: "second", value: "nil") + ParameterExp(name: "first", value: "self.rawValue") + ParameterExp(name: "second", value: "nil") } } } @@ -62,17 +62,14 @@ let structExample = Struct("BlackjackCard") { Variable(.let, name: "rank", type: "Rank") Variable(.let, name: "suit", type: "Suit") - ComputedProperty("description") { - VariableDecl(.var, name: "output", equals: "suit is \(suit.rawValue),") - PlusAssign("output", " value is \(rank.values.first)") + ComputedProperty("description", type: "String") { + Variable(.var, name: "output", equals: "suit is \\(suit.rawValue),") + PlusAssign("output", " value is \\(rank.values.first)") If(Let("second", "rank.values.second"), then: { - PlusAssign("output", " or \(second)") + PlusAssign("output", " or \\(second)") }) Return { VariableExp("output") } } } - -// Generate and print the code -print(structExample.generateCode()) diff --git a/Examples/Completed/card_game/dsl.swift b/Examples/Completed/card_game/dsl.swift index f6f9bdd0..0374e512 100644 --- a/Examples/Completed/card_game/dsl.swift +++ b/Examples/Completed/card_game/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Example of generating a BlackjackCard struct with a nested Suit enum -let structExample = Group { +Group { Struct("Card") { Variable(.let, name: "rank", type: "Rank") .comment{ @@ -39,31 +39,31 @@ let structExample = Group { Variable(.let, name: "first", type: "Int") Variable(.let, name: "second", type: "Int?") } - ComputedProperty("description") { + ComputedProperty("description", type: "String") { Switch("self") { SwitchCase(".jack") { - Return{ - Literal("\"J\"") + Return { + Literal.string("J") } } SwitchCase(".queen") { - Return{ - Literal("\"Q\"") + Return { + Literal.string("Q") } } SwitchCase(".king") { - Return{ - Literal("\"K\"") + Return { + Literal.string("K") } } SwitchCase(".ace") { - Return{ - Literal("\"A\"") + Return { + Literal.string("A") } } Default { - Return{ - Literal("\\(rawValue)") + Return { + Literal.string("\\(rawValue)") } } } @@ -92,5 +92,3 @@ let structExample = Group { } } -// Generate and print the code -print(structExample.generateCode()) diff --git a/Examples/Completed/concurrency/dsl.swift b/Examples/Completed/concurrency/dsl.swift index 300a4609..d0f66dd8 100644 --- a/Examples/Completed/concurrency/dsl.swift +++ b/Examples/Completed/concurrency/dsl.swift @@ -1,48 +1,51 @@ Enum("VendingMachineError") { - Case("invalidSelection") - Case("insufficientFunds").associatedValue("coinsNeeded", type: "Int") - Case("outOfStock") + EnumCase("invalidSelection") + EnumCase("insufficientFunds").associatedValue("coinsNeeded", type: "Int") + EnumCase("outOfStock") } +.inherits("Error") Class("VendingMachine") { - Variable(.var, name: "inventory", equals: Literal.dictionary(Dictionary(uniqueKeysWithValues: [ - ("Candy Bar", Item(price: 12, count: 7)), - ("Chips", Item(price: 10, count: 4)), - ("Pretzels", Item(price: 7, count: 11)) - ]))) + // Dictionary values are `Init`-expressions of an external `Item` type, which + // Literal.dictionary's typed cases can't represent — emit the literal as raw + // Swift source via VariableExp. + Variable(.var, name: "inventory") { + VariableExp(""" + [ + "Candy Bar": Item(price: 12, count: 7), + "Chips": Item(price: 10, count: 4), + "Pretzels": Item(price: 7, count: 11) + ] + """) + } Variable(.var, name: "coinsDeposited", equals: 0) - Function("vend"){ + Function("vend") { Parameter("name", labeled: "itemNamed", type: "String") } _: { - Guard("let item = inventory[itemNamed]") else: { - Throw( - EnumValue("VendingMachineError", case: "invalidSelection") - ) + Guard { + Let("item", "inventory[itemNamed]") + } else: { + Throw(VariableExp("VendingMachineError.invalidSelection")) } - Guard("item.count > 0") else: { - Throw( - EnumValue("VendingMachineError", case: "outOfStock") - ) + Guard { + Infix(">", lhs: VariableExp("item.count"), rhs: Literal.integer(0)) + } else: { + Throw(VariableExp("VendingMachineError.outOfStock")) } - Guard("item.price <= coinsDeposited") else: { - Throw( - EnumValue("VendingMachineError", case: "insufficientFunds"){ - ParameterExp("coinsNeeded", value: Infix("-"){ - VariableExp("item").property("price") - VariableExp("coinsDeposited") - }) - } - ) + Guard { + Infix("<=", lhs: VariableExp("item.price"), rhs: VariableExp("coinsDeposited")) + } else: { + Throw(VariableExp( + "VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)" + )) } - Infix("-=", "coinsDeposited", VariableExp("item").property("price")) - Variable("newItem", equals: VariableExp("item")) - Infix("-=", "newItem.count", 1) - Assignment("inventory[itemNamed]", .ref("newItem")) - Call("print", "Dispensing \\(itemNamed)") - } + Infix("-=", lhs: VariableExp("coinsDeposited"), rhs: VariableExp("item.price")) + Variable(.var, name: "newItem") { VariableExp("item") } + Infix("-=", lhs: VariableExp("newItem.count"), rhs: Literal.integer(1)) + Assignment("inventory[itemNamed]", VariableExp("newItem")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Dispensing \\(itemNamed)")) + } + }.throws() } - - - - diff --git a/Examples/Completed/conditionals/dsl.swift b/Examples/Completed/conditionals/dsl.swift index 70263e0c..ef057023 100644 --- a/Examples/Completed/conditionals/dsl.swift +++ b/Examples/Completed/conditionals/dsl.swift @@ -4,31 +4,31 @@ Group { Line("Simple if statement") } If { - Infix("temperature", ">", 30) + Infix(">", lhs: VariableExp("temperature"), rhs: Literal.integer(30)) } then: { - Call("print", "It's hot outside!") + Call("print") { ParameterExp(unlabeled: Literal.string("It's hot outside!")) } } Variable(.let, name: "score", equals: Literal.integer(85)) .comment { Line("If-else statement") } If { - Infix("score", ">=", 90) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { - Call("print", "Excellent!") + Call("print") { ParameterExp(unlabeled: Literal.string("Excellent!")) } } else: { If { - Infix("score", ">=", 80) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { - Call("print", "Good job!") + Call("print") { ParameterExp(unlabeled: Literal.string("Good job!")) } } If { - try Infix("score", ">=", 70) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(70)) } then: { - Call("print", "Passing") + Call("print") { ParameterExp(unlabeled: Literal.string("Passing")) } } Then { - Call("print", "Needs improvement") + Call("print") { ParameterExp(unlabeled: Literal.string("Needs improvement")) } } } @@ -40,9 +40,9 @@ Group { If(Let("actualNumber", Init("Int") { ParameterExp(name: "", value: "possibleNumber") }), then: { - Call("print", "The string \"\\(possibleNumber)\" has an integer value of \\(actualNumber)") + Call("print") { ParameterExp(unlabeled: Literal.string("The string \"\\(possibleNumber)\" has an integer value of \\(actualNumber)")) } }, else: { - Call("print", "The string \"\\(possibleNumber)\" could not be converted to an integer") + Call("print") { ParameterExp(unlabeled: Literal.string("The string \"\\(possibleNumber)\" could not be converted to an integer")) } }) Variable(.let, name: "possibleName", type: "String?", equals: Literal.string("John")).withExplicitType() @@ -54,14 +54,16 @@ Group { Let("name", "possibleName") Let("age", "possibleAge") } then: { - Call("print", "\\(name) is \\(age) years old") + Call("print") { ParameterExp(unlabeled: Literal.string("\\(name) is \\(age) years old")) } } - Function("greet", parameters: [Parameter("person", type: "[String: String]")]) { + Function("greet") { + Parameter(name: "person", type: "[String: String]") + } _: { Guard { Let("name", "person[\"name\"]") } else: { - Call("print", "No name provided") + Call("print") { ParameterExp(unlabeled: Literal.string("No name provided")) } } Guard { Let("age", "person[\"age\"]") @@ -69,9 +71,9 @@ Group { ParameterExp(name: "", value: "age") }) } else: { - Call("print", "Invalid age provided") + Call("print") { ParameterExp(unlabeled: Literal.string("Invalid age provided")) } } - Call("print", "Hello \\(name), you are \\(ageInt) years old") + Call("print") { ParameterExp(unlabeled: Literal.string("Hello \\(name), you are \\(ageInt) years old")) } } }.comment { Line("MARK: - Guard Statements") @@ -104,26 +106,26 @@ Switch("approximateCount") { Assignment("naturalCount", Literal.string("many")) } } -Call("print", "There are \\(naturalCount) \\(countedThings).") +Call("print") { ParameterExp(unlabeled: Literal.string("There are \\(naturalCount) \\(countedThings).")) } Variable(.let, name: "somePoint", type: "(Int, Int)", equals: VariableExp("(1, 1)"), explicitType: true) .comment { Line("Switch with tuple matching") } Switch("somePoint") { SwitchCase(Tuple.pattern([0, 0])) { - Call("print", "(0, 0) is at the origin") + Call("print") { ParameterExp(unlabeled: Literal.string("(0, 0) is at the origin")) } } SwitchCase(Tuple.pattern([nil, 0])) { - Call("print", "(\(somePoint.0), 0) is on the x-axis") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), 0) is on the x-axis")) } } SwitchCase(Tuple.pattern([0, nil])) { - Call("print", "(0, \(somePoint.1)) is on the y-axis") + Call("print") { ParameterExp(unlabeled: Literal.string("(0, \\(somePoint.1)) is on the y-axis")) } } SwitchCase(Tuple.pattern([(-2...2), (-2...2)])) { - Call("print", "(\(somePoint.0), \(somePoint.1)) is inside the box") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), \\(somePoint.1)) is inside the box")) } } Default { - Call("print", "(\(somePoint.0), \(somePoint.1)) is outside of the box") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), \\(somePoint.1)) is outside of the box")) } } } Variable(.let, name: "anotherPoint", type: "(Int, Int)", equals: VariableExp("(2, 0)"), explicitType: true) @@ -131,21 +133,21 @@ Variable(.let, name: "anotherPoint", type: "(Int, Int)", equals: VariableExp("(2 Line("Switch with value binding") } Switch("anotherPoint") { - SwitchCase(Tuple.pattern([.let("x"), 0])) { - Call("print", "on the x-axis with an x value of \(x)") + SwitchCase(Tuple.pattern([Pattern.let("x"), 0])) { + Call("print") { ParameterExp(unlabeled: Literal.string("on the x-axis with an x value of \\(x)")) } } - SwitchCase(Tuple.pattern([0, .let("y")])) { - Call("print", "on the y-axis with a y value of \(y)") + SwitchCase(Tuple.pattern([0, Pattern.let("y")])) { + Call("print") { ParameterExp(unlabeled: Literal.string("on the y-axis with a y value of \\(y)")) } } - SwitchCase(Tuple.pattern([.let("x"), .let("y")])) { - Call("print", "somewhere else at (\(x), \(y))") + SwitchCase(Tuple.pattern([Pattern.let("x"), Pattern.let("y")])) { + Call("print") { ParameterExp(unlabeled: Literal.string("somewhere else at (\\(x), \\(y))")) } } } Variable(.let, name: "integerToDescribe", equals: 5) -Variable(.var, name: "description", equals: "The number \(integerToDescribe) is") +Variable(.var, name: "description", equals: "The number \\(integerToDescribe) is") Switch("integerToDescribe") { SwitchCase(2, 3, 5, 7, 11, 13, 17, 19) { PlusAssign("description", "a prime number, and also") @@ -155,68 +157,62 @@ Switch("integerToDescribe") { PlusAssign("description", "an integer.") } } -Call("print", "description") +Call("print") { ParameterExp(unlabeled: Literal.string("description")) } Variable(.let, name: "finalSquare", equals: 25) Variable(.var, name: "board", equals: Literal.array(Array(repeating: Literal.integer(0), count: 26))) -Infix("board[03]", "+=", 8) -Infix("board[06]", "+=", 11) -Infix("board[09]", "+=", 9) -Infix("board[10]", "+=", 2) -Infix("board[14]", "-=", 10) -Infix("board[19]", "-=", 11) -Infix("board[22]", "-=", 2) -Infix("board[24]", "-=", 8) +Infix("+=", lhs: VariableExp("board[03]"), rhs: Literal.integer(8)) +Infix("+=", lhs: VariableExp("board[06]"), rhs: Literal.integer(11)) +Infix("+=", lhs: VariableExp("board[09]"), rhs: Literal.integer(9)) +Infix("+=", lhs: VariableExp("board[10]"), rhs: Literal.integer(2)) +Infix("-=", lhs: VariableExp("board[14]"), rhs: Literal.integer(10)) +Infix("-=", lhs: VariableExp("board[19]"), rhs: Literal.integer(11)) +Infix("-=", lhs: VariableExp("board[22]"), rhs: Literal.integer(2)) +Infix("-=", lhs: VariableExp("board[24]"), rhs: Literal.integer(8)) Variable(.var, name: "square", equals: 0) Variable(.var, name: "diceRoll", equals: 0) -While { - try Infix("square", "!=", "finalSquare") -} then: { - Assignment("diceRoll", "+", 1) +While(Infix("!=", lhs: VariableExp("square"), rhs: VariableExp("finalSquare"))) { + Infix("+=", lhs: VariableExp("diceRoll"), rhs: Literal.integer(1)) If { - try Infix("diceRoll", "==", 7) + Infix("==", lhs: VariableExp("diceRoll"), rhs: Literal.integer(7)) } then: { Assignment("diceRoll", 1) } - Switch(try Infix("square", "+", "diceRoll")) { + Switch(Infix("+", lhs: VariableExp("square"), rhs: VariableExp("diceRoll"))) { SwitchCase("finalSquare") { Break() } - SwitchCase(try Infix("newSquare", ">", "finalSquare")) { + SwitchCase(Infix(">", lhs: VariableExp("newSquare"), rhs: VariableExp("finalSquare"))) { Continue() } Default { - try Infix("square", "+=", "diceRoll") - try Infix("square", "+=", "board[square]") + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("diceRoll")) + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("board[square]")) } } } -Call("print", "\n=== For-in with Enumerated ===") +Call("print") { ParameterExp(unlabeled: Literal.string("\n=== For-in with Enumerated ===")) } .comment { Line("MARK: - For Loops") Line("For-in loop with enumerated() to get index and value") } -For { - Tuple.pattern([VariableExp("index"), VariableExp("name")]) -} in: { - VariableExp("names").call("enumerated") -} then: { - Call("print", "Index: \\(index), Name: \\(name)") -} +For(Tuple.patternCodeBlock([VariableExp("index"), VariableExp("name")]), + in: VariableExp("names").call("enumerated"), + then: { + Call("print") { ParameterExp(unlabeled: Literal.string("Index: \\(index), Name: \\(name)")) } + }) -Call("print", "\n=== For-in with Where Clause ===") +Call("print") { ParameterExp(unlabeled: Literal.string("\n=== For-in with Where Clause ===")) } .comment { Line("For-in loop with where clause") } -For { - VariableExp("numbers") -} in: { - Literal.array([Literal.integer(1), Literal.integer(2), Literal.integer(3), Literal.integer(4), Literal.integer(5), Literal.integer(6), Literal.integer(7), Literal.integer(8), Literal.integer(9), Literal.integer(10)]) -} where: { - try Infix("number", "%", 2) -} then: { - Call("print", "Even number: \\(number)") -} +For(VariableExp("number"), + in: VariableExp("numbers"), + then: { + If(VariableExp("number % 2 == 0"), then: { + Call("print") { ParameterExp(unlabeled: Literal.string("Even number: \\(number)")) } + }) + }) diff --git a/Examples/Completed/for_loops/dsl.swift b/Examples/Completed/for_loops/dsl.swift index db060096..74b1d1f9 100644 --- a/Examples/Completed/for_loops/dsl.swift +++ b/Examples/Completed/for_loops/dsl.swift @@ -40,13 +40,11 @@ Group { Variable(.let, name: "numbers", equals: Literal.array([Literal.integer(1), Literal.integer(2), Literal.integer(3), Literal.integer(4), Literal.integer(5), Literal.integer(6), Literal.integer(7), Literal.integer(8), Literal.integer(9), Literal.integer(10)])) For(VariableExp("number"), in: VariableExp("numbers"), where: { - try Infix("==") { - try Infix("%") { - VariableExp("number") - Literal.integer(2) - } - Literal.integer(0) - } + Infix("==", + lhs: Infix("%", + lhs: VariableExp("number"), + rhs: Literal.integer(2)), + rhs: Literal.integer(0)) }, then: { Call("print") { ParameterExp(unlabeled: "\"Even number: \\(number)\"") diff --git a/Examples/Completed/macro_tutorial/dsl.swift b/Examples/Completed/macro_tutorial/dsl.swift index 490051fa..0cea4cf3 100644 --- a/Examples/Completed/macro_tutorial/dsl.swift +++ b/Examples/Completed/macro_tutorial/dsl.swift @@ -1,276 +1,217 @@ import SyntaxKit // MARK: - Macro Tutorial DSL Examples -// This file shows how SyntaxKit DSL would be used to generate the macro examples - -// MARK: - Example 1: Extension Macro Generation -// This shows how to generate the extension that @MyMacro would create - -let colorExtension = Extension("Color") { - // Add a type alias - TypeAlias("MyType", equals: "String") - - // Add a static property with case names - Variable(.let, name: "myProperty", equals: ["red", "green", "blue"]).static() - - // Add a computed property - ComputedProperty("description") { - Return { - VariableExp("myProperty.joined(separator: \", \")") +// +// This file demonstrates the SyntaxKit DSL patterns one would use to generate +// the kind of code a Swift macro produces. Each example is a top-level block; +// skitrun concatenates them into a single rendered file. (The original +// version of this file used per-example `let` bindings and a final `print`, +// which skitrun's wrapper doesn't accept — top-level expressions only.) + +Group { + // MARK: Example 1 — Extension Macro Generation + Extension("Color") { + TypeAlias("MyType", equals: "String") + Variable(.let, name: "myProperty", equals: ["red", "green", "blue"]).static() + ComputedProperty("description", type: "String") { + Return { VariableExp("myProperty.joined(separator: \", \")") } + } + }.inherits("MyProtocol") + + // MARK: Example 2 — Peer Macro Generation + // + // The original example declared `init(value: Color) { self.value = value }` + // via `Init { Parameter(...) }`, which isn't part of the current public DSL + // (Init is an expression-only call here). Emit the initializer body as raw + // Swift instead. + Struct("ColorWrapper") { + Variable(.let, name: "value", type: "Color") + VariableExp(""" + init(value: Color) { + self.value = value + } + """) + ComputedProperty("description", type: "String") { + Return { VariableExp("value.description") } } } -}.inherits("MyProtocol") - -// MARK: - Example 2: Peer Macro Generation -// This shows how to generate the wrapper struct that @MyMacro would create -let colorWrapper = Struct("ColorWrapper") { - Variable(.let, name: "value", type: "Color") - - Init { - Parameter(name: "value", type: "Color") + // MARK: Example 3 — Freestanding Expression Macro Generation + Tuple { + VariableExp("42 + 8") + Literal.string("42 + 8") } - - ComputedProperty("description") { - Return { - VariableExp("value.description") - } - } -} -// MARK: - Example 3: Freestanding Expression Macro Generation -// This shows how to generate the tuple that #stringify would create + // MARK: Example 4 — Complex Extension Generation + Extension("User") { + Enum("Status") { + EnumCase("active").equals("active") + EnumCase("inactive").equals("inactive") + EnumCase("pending").equals("pending") + }.inherits("String") + + ComputedProperty("isValid", type: "Bool") { + If(VariableExp("status == .active"), then: { + Return { Literal.boolean(true) } + }, else: { + Return { Literal.boolean(false) } + }) + } -let stringifyResult = Tuple { - VariableExp("42 + 8") - Literal.string("42 + 8") -} + Function("updateStatus") { + Parameter(name: "newStatus", type: "Status") + } _: { + Assignment("status", VariableExp("newStatus")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Status updated to \\(newStatus)")) + } + } -// MARK: - Example 4: Complex Extension Generation -// This shows how to generate a complex extension with multiple members + Function("createDefault") { + } _: { + Return { + Init("User") { + ParameterExp(name: "status", value: VariableExp(".pending")) + ParameterExp(name: "name", value: Literal.string("Default")) + } + } + }.static() + }.inherits("Identifiable", "Codable") + + // MARK: Example 5 — Error Handling Structure + Enum("MacroError") { + EnumCase("onlyWorksWithEnums") + EnumCase("invalidCaseName").associatedValue("name", type: "String") + EnumCase("missingRawValue") + }.inherits("Error", "CustomStringConvertible") + + // MARK: Example 8 — Protocol Generation + Protocol("MyProtocol") { + PropertyRequirement("description", type: "String", access: .get) + } -let userExtension = Extension("User") { - // Add a nested enum - Enum("Status") { - EnumCase("active").equals("active") - EnumCase("inactive").equals("inactive") - EnumCase("pending").equals("pending") - }.inherits("String") - - // Add a computed property with complex logic - ComputedProperty("isValid") { - If(VariableExp("status == .active"), then: { - Return { Literal.boolean(true) } - }, else: { - Return { Literal.boolean(false) } + // MARK: Example 9 — Complex Control Flow Generation + Function("processData") { + Parameter(name: "data", type: "[String]") + } _: { + Variable(.var, name: "result", equals: "[]") + For(VariableExp("item"), in: VariableExp("data"), then: { + If(VariableExp("item.hasPrefix(\"test\")"), then: { + Call("result.append") { + ParameterExp(unlabeled: VariableExp("item.uppercased()")) + } + }, else: { + Call("result.append") { + ParameterExp(unlabeled: VariableExp("item.lowercased()")) + } + }) }) + Return { VariableExp("result") } } - - // Add a method with parameters - Function("updateStatus", parameters: [Parameter("newStatus", type: "Status")]) { - Assignment("status", VariableExp("newStatus")) - Call("print") { - ParameterExp(unlabeled: "\"Status updated to \\(newStatus)\"") - } - } - - // Add a static method - Function("createDefault", parameters: []) { - Return { - Init("User") { - Parameter(name: "status", value: ".pending") - Parameter(name: "name", value: "\"Default\"") + + // MARK: Example 10 — Nested Structure Generation + Struct("ComplexStruct") { + Enum("NestedEnum") { + EnumCase("case1") + EnumCase("case2").equals("value2") + }.inherits("String") + + Struct("NestedStruct") { + Variable(.let, name: "id", type: "UUID") + Variable(.var, name: "name", type: "String") + + VariableExp(""" + init(name: String) { + self.id = UUID() + self.name = name + } + """) + + ComputedProperty("displayName", type: "String") { + Return { + VariableExp("name.isEmpty ? \"Unknown\" : name") + } } } - }.static() - -}.inherits("Identifiable", "Codable") - -// MARK: - Example 5: Error Handling Structure -// This shows how to generate error handling code -let macroError = Enum("MacroError") { - EnumCase("onlyWorksWithEnums") - EnumCase("invalidCaseName").associatedValue("name", type: "String") - EnumCase("missingRawValue") -}.inherits("Error", "CustomStringConvertible") + Variable(.let, name: "enumValue", type: "NestedEnum") + Variable(.var, name: "structValue", type: "NestedStruct") -// MARK: - Example 6: Test Code Generation -// This shows how to generate test code for macros - -let testFunction = Function("testExtensionMacro", parameters: []) { - Call("assertMacroExpansion") { - ParameterExp(name: "input", value: "\"\"\"\n@MyMacro\nenum Color: String {\n case red = \"red\"\n case blue = \"blue\"\n}\n\"\"\"") - ParameterExp(name: "expected", value: "\"\"\"\nenum Color: String {\n case red = \"red\"\n case blue = \"blue\"\n}\n\nextension Color: MyProtocol {\n typealias MyType = String\n static let myProperty = [\"red\", \"blue\"]\n var description: String {\n return myProperty.joined(separator: \", \")\n }\n}\n\nstruct ColorWrapper {\n let value: Color\n init(value: Color) {\n self.value = value\n }\n var description: String {\n return value.description\n }\n}\n\"\"\"") - ParameterExp(name: "macros", value: "[\"MyMacro\": MyMacro.self]") + Function("updateName") { + Parameter(name: "newName", type: "String") + } _: { + Assignment("structValue.name", VariableExp("newName")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Name updated to: \\(newName)")) + } + } } -}.throws() -// MARK: - Example 7: Integration Example -// This shows how to mix SyntaxKit with raw SwiftSyntax - -let baseStruct = Struct("Generated") { - Variable(.let, name: "value", type: "String") -} - -// Convert to SwiftSyntax and modify (this would be done in the macro) -// var structDecl = baseStruct.syntax.as(StructDeclSyntax.self)! -// structDecl = structDecl.with(\.modifiers, DeclModifierListSyntax { -// DeclModifierSyntax(name: .keyword(.public)) -// }) - -// MARK: - Example 8: Protocol Generation -// This shows how to generate protocols that macros might need - -let myProtocol = Protocol("MyProtocol") { - PropertyRequirement("description", type: "String", access: .get) -} - -// MARK: - Example 9: Complex Control Flow Generation -// This shows how to generate complex control flow in macros - -let complexFunction = Function("processData", parameters: [Parameter("data", type: "[String]")]) { - Variable(.var, name: "result", equals: "[]") - - For(VariableExp("item"), in: VariableExp("data"), then: { - If(VariableExp("item.hasPrefix(\"test\")"), then: { - Call("result.append") { - ParameterExp(unlabeled: "item.uppercased()") + // MARK: Example 11 — Switch Statement Generation + Function("handleStatus") { + Parameter(name: "status", type: "UserStatus") + } _: { + Switch("status") { + SwitchCase(".active") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User is active")) + } + Return { Literal.boolean(true) } } - }, else: { - Call("result.append") { - ParameterExp(unlabeled: "item.lowercased()") + SwitchCase(".inactive") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User is inactive")) + } + Return { Literal.boolean(false) } } - }) - }) - - Return { - VariableExp("result") - } -} - -// MARK: - Example 10: Nested Structure Generation -// This shows how to generate nested structures - -let complexStruct = Struct("ComplexStruct") { - // Nested enum - Enum("NestedEnum") { - EnumCase("case1") - EnumCase("case2").equals("value2") - }.inherits("String") - - // Nested struct - Struct("NestedStruct") { - Variable(.let, name: "id", type: "UUID") - Variable(.var, name: "name", type: "String") - - Init { - Parameter(name: "name", type: "String") - } - - ComputedProperty("displayName") { - Return { - VariableExp("name.isEmpty ? \"Unknown\" : name") + SwitchCase(".pending") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User status is pending")) + } + Return { Literal.boolean(false) } + } + Default { + Call("print") { + ParameterExp(unlabeled: Literal.string("Unknown status")) + } + Return { Literal.boolean(false) } } } } - - // Properties - Variable(.let, name: "enumValue", type: "NestedEnum") - Variable(.var, name: "structValue", type: "NestedStruct") - - // Methods - Function("updateName", parameters: [Parameter("newName", type: "String")]) { - Assignment("structValue.name", VariableExp("newName")) - Call("print") { - ParameterExp(unlabeled: "\"Name updated to: \\(newName)\"") - } - } -} - -// MARK: - Example 11: Switch Statement Generation -// This shows how to generate switch statements in macros -let switchFunction = Function("handleStatus", parameters: [Parameter("status", type: "UserStatus")]) { - Switch("status") { - SwitchCase(".active") { + // MARK: Example 12 — Guard Statement Generation + Function("validateUser") { + Parameter(name: "user", type: "User?") + } _: { + Guard { + Let("user", "user") + } else: { Call("print") { - ParameterExp(unlabeled: "\"User is active\"") + ParameterExp(unlabeled: Literal.string("User is nil")) } - Return { Literal.boolean(true) } + Return { Literal.boolean(false) } } - SwitchCase(".inactive") { + + Guard { + Let("name", "user.name") + Let("nameLength", "name.count") + } else: { Call("print") { - ParameterExp(unlabeled: "\"User is inactive\"") + ParameterExp(unlabeled: Literal.string("Invalid user name")) } Return { Literal.boolean(false) } } - SwitchCase(".pending") { + + If(VariableExp("nameLength > 0"), then: { Call("print") { - ParameterExp(unlabeled: "\"User status is pending\"") + ParameterExp(unlabeled: Literal.string("User \\(name) is valid")) } - Return { Literal.boolean(false) } - } - Default { + Return { Literal.boolean(true) } + }, else: { Call("print") { - ParameterExp(unlabeled: "\"Unknown status\"") + ParameterExp(unlabeled: Literal.string("User name is empty")) } Return { Literal.boolean(false) } - } - } -} - -// MARK: - Example 12: Guard Statement Generation -// This shows how to generate guard statements in macros - -let guardFunction = Function("validateUser", parameters: [Parameter("user", type: "User?")]) { - Guard { - Let("user", "user") - } else: { - Call("print") { - ParameterExp(unlabeled: "\"User is nil\"") - } - Return { Literal.boolean(false) } - } - - Guard { - Let("name", "user.name") - Let("nameLength", "name.count") - } else: { - Call("print") { - ParameterExp(unlabeled: "\"Invalid user name\"") - } - Return { Literal.boolean(false) } + }) } - - If(VariableExp("nameLength > 0"), then: { - Call("print") { - ParameterExp(unlabeled: "\"User \\(name) is valid\"") - } - Return { Literal.boolean(true) } - }, else: { - Call("print") { - ParameterExp(unlabeled: "\"User name is empty\"") - } - Return { Literal.boolean(false) } - }) } - -// MARK: - Generate All Examples - -let allExamples = Group { - colorExtension - colorWrapper - stringifyResult - userExtension - macroError - testFunction - myProtocol - complexFunction - complexStruct - switchFunction - guardFunction -} - -// Print the generated code -print(allExamples.generateCode()) \ No newline at end of file diff --git a/Examples/Completed/protocols/dsl.swift b/Examples/Completed/protocols/dsl.swift index bd1794f9..d44aec90 100644 --- a/Examples/Completed/protocols/dsl.swift +++ b/Examples/Completed/protocols/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Generate and print the code -let generatedCode = Group { +Group { // MARK: - Protocol Definition Protocol("Vehicle") { PropertyRequirement("numberOfWheels", type: "Int", access: .get) @@ -57,8 +57,13 @@ let generatedCode = Group { }.inherits("Vehicle") // MARK: - Usage Example - VariableDecl(.let, name: "tesla", equals: "ElectricCar(brand: \"Tesla\", batteryLevel: 75.0)") - VariableDecl(.let, name: "toyota", equals: "Car(brand: \"Toyota\")") + Variable(.let, name: "tesla", equals: Init("ElectricCar") { + ParameterExp(name: "brand", value: Literal.string("Tesla")) + ParameterExp(name: "batteryLevel", value: Literal.float(75.0)) + }) + Variable(.let, name: "toyota", equals: Init("Car") { + ParameterExp(name: "brand", value: Literal.string("Toyota")) + }) // Demonstrate protocol usage Function("demonstrateVehicle") { @@ -103,4 +108,3 @@ let generatedCode = Group { } } -print(generatedCode.generateCode()) \ No newline at end of file diff --git a/Examples/Completed/swiftui/dsl.swift b/Examples/Completed/swiftui/dsl.swift index 4e2f97cb..d2822ce2 100644 --- a/Examples/Completed/swiftui/dsl.swift +++ b/Examples/Completed/swiftui/dsl.swift @@ -1,7 +1,7 @@ -Import("SwiftUI").access("public") +Import("SwiftUI").access(.public) Struct("TodoItemRow") { - Variable(.let, name: "item", type: "TodoItem").access("private") + Variable(.let, name: "item", type: "TodoItem").access(.private) Variable(.let, name: "onToggle", type: ClosureType(returns: "Void"){ @@ -10,7 +10,7 @@ Struct("TodoItemRow") { .attribute("@MainActor") .attribute("@Sendable") ) - .access("private") + .access(.private) ComputedProperty("body", type: "some View") { Init("HStack") { @@ -21,13 +21,13 @@ Struct("TodoItemRow") { ParameterExp(unlabeled: Closure{ Init("Image") { ParameterExp(name: "systemName", value: ConditionalOp( - if: VariableExp("item").property(name: "isCompleted"), + if: VariableExp("item").property("isCompleted"), then: Literal.string("checkmark.circle.fill"), else: Literal.string("circle") )) }.call("foregroundColor"){ ParameterExp(unlabeled: ConditionalOp( - if: VariableExp("item").property(name: "isCompleted"), + if: VariableExp("item").property("isCompleted"), then: EnumCase("green"), else: EnumCase("gray") )) @@ -39,7 +39,7 @@ Struct("TodoItemRow") { Init("Task") { ParameterExp(unlabeled: Closure( capture: { - ParameterExp(unlabeled: VariableExp("self").reference("weak")) + ParameterExp(unlabeled: VariableExp("self").reference(.weak)) }, body: { VariableExp("self").optional().call("onToggle") { @@ -62,4 +62,4 @@ Struct("TodoItemRow") { } } .inherits("View") -.access("public") +.access(.public) diff --git a/Examples/Completed/enum_generator/EnumGeneratorExample.swift b/Examples/Demos/enum_generator/EnumGeneratorExample.swift similarity index 100% rename from Examples/Completed/enum_generator/EnumGeneratorExample.swift rename to Examples/Demos/enum_generator/EnumGeneratorExample.swift diff --git a/Examples/Completed/enum_generator/INTEGRATION_GUIDE.md b/Examples/Demos/enum_generator/INTEGRATION_GUIDE.md similarity index 98% rename from Examples/Completed/enum_generator/INTEGRATION_GUIDE.md rename to Examples/Demos/enum_generator/INTEGRATION_GUIDE.md index a355d1ed..0302a80a 100644 --- a/Examples/Completed/enum_generator/INTEGRATION_GUIDE.md +++ b/Examples/Demos/enum_generator/INTEGRATION_GUIDE.md @@ -6,7 +6,7 @@ This guide demonstrates the real-world impact of using SyntaxKit for dynamic enu ```bash # See the value proposition in action -cd Examples/Completed/enum_generator +cd Examples/Demos/enum_generator swift demo.swift ``` diff --git a/Examples/Completed/enum_generator/Package.resolved b/Examples/Demos/enum_generator/Package.resolved similarity index 100% rename from Examples/Completed/enum_generator/Package.resolved rename to Examples/Demos/enum_generator/Package.resolved diff --git a/Examples/Completed/enum_generator/Package.swift b/Examples/Demos/enum_generator/Package.swift similarity index 100% rename from Examples/Completed/enum_generator/Package.swift rename to Examples/Demos/enum_generator/Package.swift diff --git a/Examples/Completed/enum_generator/README.md b/Examples/Demos/enum_generator/README.md similarity index 100% rename from Examples/Completed/enum_generator/README.md rename to Examples/Demos/enum_generator/README.md diff --git a/Examples/Completed/enum_generator/after/Generated.swift b/Examples/Demos/enum_generator/after/Generated.swift similarity index 100% rename from Examples/Completed/enum_generator/after/Generated.swift rename to Examples/Demos/enum_generator/after/Generated.swift diff --git a/Examples/Completed/enum_generator/after/enum_generator.swift b/Examples/Demos/enum_generator/after/enum_generator.swift similarity index 100% rename from Examples/Completed/enum_generator/after/enum_generator.swift rename to Examples/Demos/enum_generator/after/enum_generator.swift diff --git a/Examples/Completed/enum_generator/api-config.json b/Examples/Demos/enum_generator/api-config.json similarity index 100% rename from Examples/Completed/enum_generator/api-config.json rename to Examples/Demos/enum_generator/api-config.json diff --git a/Examples/Completed/enum_generator/before/APIEndpoint.swift b/Examples/Demos/enum_generator/before/APIEndpoint.swift similarity index 100% rename from Examples/Completed/enum_generator/before/APIEndpoint.swift rename to Examples/Demos/enum_generator/before/APIEndpoint.swift diff --git a/Examples/Completed/enum_generator/before/HTTPStatus.swift b/Examples/Demos/enum_generator/before/HTTPStatus.swift similarity index 100% rename from Examples/Completed/enum_generator/before/HTTPStatus.swift rename to Examples/Demos/enum_generator/before/HTTPStatus.swift diff --git a/Examples/Completed/enum_generator/before/NetworkError.swift b/Examples/Demos/enum_generator/before/NetworkError.swift similarity index 100% rename from Examples/Completed/enum_generator/before/NetworkError.swift rename to Examples/Demos/enum_generator/before/NetworkError.swift diff --git a/Examples/Completed/enum_generator/code.swift b/Examples/Demos/enum_generator/code.swift similarity index 100% rename from Examples/Completed/enum_generator/code.swift rename to Examples/Demos/enum_generator/code.swift diff --git a/Examples/Completed/enum_generator/demo.swift b/Examples/Demos/enum_generator/demo.swift similarity index 100% rename from Examples/Completed/enum_generator/demo.swift rename to Examples/Demos/enum_generator/demo.swift diff --git a/Examples/Completed/enum_generator/dsl.swift b/Examples/Demos/enum_generator/dsl.swift similarity index 100% rename from Examples/Completed/enum_generator/dsl.swift rename to Examples/Demos/enum_generator/dsl.swift diff --git a/Examples/Completed/enum_generator/generate.swift b/Examples/Demos/enum_generator/generate.swift similarity index 94% rename from Examples/Completed/enum_generator/generate.swift rename to Examples/Demos/enum_generator/generate.swift index d403b4e3..ea13b075 100644 --- a/Examples/Completed/enum_generator/generate.swift +++ b/Examples/Demos/enum_generator/generate.swift @@ -8,7 +8,7 @@ let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/swift") process.arguments = [ "run", "--package-path", "../../../", "swift", - "Examples/Completed/enum_generator/dsl.swift" + "Examples/Demos/enum_generator/dsl.swift" ] process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) diff --git a/Examples/Completed/enum_generator/integration_demo.swift b/Examples/Demos/enum_generator/integration_demo.swift similarity index 100% rename from Examples/Completed/enum_generator/integration_demo.swift rename to Examples/Demos/enum_generator/integration_demo.swift diff --git a/Examples/Completed/enum_generator/main.swift b/Examples/Demos/enum_generator/main.swift similarity index 100% rename from Examples/Completed/enum_generator/main.swift rename to Examples/Demos/enum_generator/main.swift diff --git a/Package.resolved b/Package.resolved index 24cfefae..99337efe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "482d43aff5bb5c075d237e0ea17c12ee2c043b2642e459260752aa1848a20593", + "originHash" : "899b1fb6639e07a99f4d6f6c1e22b7c90948b2df293879cf18e8b0f87500bf16", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", @@ -19,6 +28,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", + "version" : "0.4.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -27,6 +45,15 @@ "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", "version" : "601.0.1" } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index ea48d243..20209397 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,19 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. +import Foundation import PackageDescription +// MARK: - Library Linkage + +// The SyntaxKit library product is normally built with automatic (static) +// linkage. The self-contained skit release bundle (Scripts/build-skit-release.sh) +// needs a dynamic libSyntaxKit.dylib instead, so it sets SYNTAXKIT_DYNAMIC_LIB=1 +// rather than patching this manifest in place. +// swiftlint:disable:next explicit_top_level_acl explicit_acl +let syntaxKitLibraryType: Product.Library.LibraryType? = + ProcessInfo.processInfo.environment["SYNTAXKIT_DYNAMIC_LIB"] == "1" ? .dynamic : nil + // MARK: - Swift Settings Configuration // swiftlint:disable:next explicit_top_level_acl explicit_acl @@ -89,6 +100,7 @@ let package = Package( products: [ .library( name: "SyntaxKit", + type: syntaxKitLibraryType, targets: ["SyntaxKit"] ), .executable( @@ -98,7 +110,10 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.1"), - .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0") + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0") ], targets: [ .target( @@ -141,12 +156,35 @@ let package = Package( ), .executableTarget( name: "skit", - dependencies: ["SyntaxParser"], + dependencies: [ + "SyntaxKit", + "SyntaxParser", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product( + name: "Subprocess", + package: "swift-subprocess", + condition: .when(platforms: [.macOS, .linux, .windows]) + ), + .product( + name: "SystemPackage", + package: "swift-system", + condition: .when(platforms: [.linux, .windows]) + ) + ], swiftSettings: swiftSettings ), .testTarget( name: "SyntaxKitTests", - dependencies: ["SyntaxKit"], + dependencies: [ + "SyntaxKit", + .product( + name: "Subprocess", + package: "swift-subprocess", + condition: .when(platforms: [.macOS, .linux, .windows]) + ) + ], swiftSettings: swiftSettings ), .testTarget( diff --git a/Scripts/build-skit.sh b/Scripts/build-skit.sh new file mode 100755 index 00000000..9860ca6d --- /dev/null +++ b/Scripts/build-skit.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Build a self-contained skit bundle. +# +# ./Scripts/build-skit.sh # release bundle → .build/skit-release/ +# ./Scripts/build-skit.sh --debug # debug bundle → .build/skit-debug/ +# +# Output: .build/skit-${CONFIG}/ +# skit ← the CLI binary +# lib/ +# libSyntaxKit.dylib ← release: stripped; debug: as-built +# *.swiftmodule ← SyntaxKit + transitively re-exported modules +# _SwiftSyntaxCShims-include/ ← C-shims headers (module map + .h files) +# swift-version.txt ← toolchain stamp for startup check +# +# Once produced, the bundle is portable: copy the whole directory anywhere, +# and `./skit-${CONFIG}/skit ` Just Works — no flags, no env vars, no +# SyntaxKit checkout required. The release variant runs a 5–15 minute +# optimized SwiftSyntax compile; the debug variant skips that and finishes +# in ~10 seconds (use it for local iteration). + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS-only. Linux uses a parallel flow (build, then strip the" >&2 + echo "Mach-O install_name step)." >&2 + exit 1 +fi + +CONFIG="release" +SWIFT_CONFIG_FLAGS=() +STRIP_DYLIB=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) + CONFIG="debug" + SWIFT_CONFIG_FLAGS=() + STRIP_DYLIB=0 + ;; + --release) + CONFIG="release" + SWIFT_CONFIG_FLAGS=(-c release) + STRIP_DYLIB=1 + ;; + -h | --help) + sed -n '2,18p' "$0" + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +# Default config is release; SWIFT_CONFIG_FLAGS was left empty so set it here. +if [[ "$CONFIG" == "release" && ${#SWIFT_CONFIG_FLAGS[@]} -eq 0 ]]; then + SWIFT_CONFIG_FLAGS=(-c release) +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/.build/skit-${CONFIG}" + +cd "$REPO_ROOT" + +# Build the SyntaxKit library product as a dynamic libSyntaxKit.dylib. Package.swift +# reads SYNTAXKIT_DYNAMIC_LIB and flips the library product to type: .dynamic, so +# we never mutate the canonical manifest. +echo "==> Building with SYNTAXKIT_DYNAMIC_LIB=1 (dynamic libSyntaxKit)" +export SYNTAXKIT_DYNAMIC_LIB=1 + +echo "==> swift build ${SWIFT_CONFIG_FLAGS[*]} --product skit" +swift build "${SWIFT_CONFIG_FLAGS[@]}" --product skit + +# `skit` doesn't depend on SyntaxKit (it spawns swift on user input that +# imports SyntaxKit at runtime). Build the library product explicitly so the +# .dynamic flip above produces libSyntaxKit.dylib + swiftmodule. +echo "==> swift build ${SWIFT_CONFIG_FLAGS[*]} --product SyntaxKit" +swift build "${SWIFT_CONFIG_FLAGS[@]}" --product SyntaxKit + +BUILD_DIR="$(ls -d .build/*-apple-macosx*/"${CONFIG}" 2>/dev/null | head -1)" +if [[ -z "$BUILD_DIR" ]]; then + echo "Could not locate ${CONFIG} build dir under .build//${CONFIG}" >&2 + exit 1 +fi + +echo "==> Staging $OUTPUT_DIR" +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR/lib" + +cp "$BUILD_DIR/skit" "$OUTPUT_DIR/skit" +cp "$BUILD_DIR/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/" +if [[ "$STRIP_DYLIB" == "1" ]]; then + strip -x "$OUTPUT_DIR/lib/libSyntaxKit.dylib" +fi +cp -r "$BUILD_DIR/Modules/." "$OUTPUT_DIR/lib/" +cp -r "$REPO_ROOT/.build/checkouts/swift-syntax/Sources/_SwiftSyntaxCShims/include" \ + "$OUTPUT_DIR/lib/_SwiftSyntaxCShims-include" + +# Ensure the dylib's install_name uses @rpath so the bundle is portable. +install_name_tool -id "@rpath/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/libSyntaxKit.dylib" 2>/dev/null || true + +# Stamp the bundle with the build toolchain. `skit` compares this against the +# user's `swift --version` at startup and refuses to spawn `swift` if the +# swiftmodule wouldn't load. Issue #157 will replace the refusal with an +# auto-rebuild fallback. +swift --version > "$OUTPUT_DIR/lib/swift-version.txt" + +BINARY_SIZE=$(ls -lh "$OUTPUT_DIR/skit" | awk '{print $5}') +DYLIB_SIZE=$(ls -lh "$OUTPUT_DIR/lib/libSyntaxKit.dylib" | awk '{print $5}') +TOTAL_SIZE=$(du -sh "$OUTPUT_DIR" | awk '{print $1}') + +echo +echo "==> ${CONFIG} bundle ready:" +echo " Binary: $BINARY_SIZE" +echo " Dylib: $DYLIB_SIZE" +echo " Total: $TOTAL_SIZE" +echo +echo "==> Try it:" +echo " $OUTPUT_DIR/skit run " diff --git a/Sources/DocumentationHarness/Validator.swift b/Sources/DocumentationHarness/Validator.swift index 85853040..07cc0347 100644 --- a/Sources/DocumentationHarness/Validator.swift +++ b/Sources/DocumentationHarness/Validator.swift @@ -33,13 +33,12 @@ package protocol Validator { func validateFile(at fileURL: URL) throws -> [ValidationResult] } -private let privateDefaultPathExtensions = ["md"] -extension Validator { +extension [String] { /// Default file extensions for documentation files - package static var defaultPathExtensions: [String] { - privateDefaultPathExtensions - } + fileprivate static let defaultPathExtensions: [String] = ["md"] +} +extension Validator { /// Validates all Swift code examples found in documentation files /// - Parameters: /// - relativePaths: Array of relative paths to search for documentation @@ -51,7 +50,7 @@ extension Validator { package func validate( relativePaths: [String], atProjectRoot projectRoot: URL, - withPathExtensions pathExtensions: [String] = Self.defaultPathExtensions, + withPathExtensions pathExtensions: [String] = .defaultPathExtensions, using fileSearcher: any FileSearcher = FileManager.default ) throws -> [ValidationResult] { let documentationFiles = try relativePaths.flatMap { docPath in diff --git a/Sources/SyntaxKit/Collections/TupleAssignment.swift b/Sources/SyntaxKit/Collections/TupleAssignment.swift index fbea0ba4..1c30d1c9 100644 --- a/Sources/SyntaxKit/Collections/TupleAssignment.swift +++ b/Sources/SyntaxKit/Collections/TupleAssignment.swift @@ -28,10 +28,10 @@ // import Foundation -import SwiftSyntax +public import SwiftSyntax /// A tuple assignment statement for destructuring multiple values. -internal struct TupleAssignment: CodeBlock { +public struct TupleAssignment: CodeBlock { private let elements: [String] private let value: any CodeBlock private var isAsync: Bool = false @@ -39,7 +39,7 @@ internal struct TupleAssignment: CodeBlock { private var isAsyncSet: Bool = false /// The syntax representation of this tuple assignment. - internal var syntax: any SyntaxProtocol { + public var syntax: any SyntaxProtocol { if isAsyncSet { return generateAsyncSetSyntax() } @@ -50,14 +50,14 @@ internal struct TupleAssignment: CodeBlock { /// - Parameters: /// - elements: The names of the variables to destructure into. /// - value: The expression to destructure. - internal init(_ elements: [String], equals value: any CodeBlock) { + public init(_ elements: [String], equals value: any CodeBlock) { self.elements = elements self.value = value } /// Marks this destructuring as async. /// - Returns: A copy of the destructuring marked as async. - internal func async() -> Self { + public func async() -> Self { var copy = self copy.isAsync = true return copy @@ -65,7 +65,7 @@ internal struct TupleAssignment: CodeBlock { /// Marks this destructuring as throwing. /// - Returns: A copy of the destructuring marked as throwing. - internal func throwing() -> Self { + public func throwing() -> Self { var copy = self copy.isThrowing = true return copy @@ -73,7 +73,7 @@ internal struct TupleAssignment: CodeBlock { /// Marks this destructuring as concurrent async (async let set). /// - Returns: A copy of the destructuring marked as async set. - internal func asyncSet() -> Self { + public func asyncSet() -> Self { var copy = self copy.isAsyncSet = true return copy diff --git a/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift b/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift new file mode 100644 index 00000000..9e7987b1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift @@ -0,0 +1,85 @@ +// +// Bundle+ResolveLibPath.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension Bundle { + /// Bundle-relative lib dir name for the adjacent layout (`/lib`). + private static let libDirectoryName = "lib" + /// Bundle-relative lib dir path for the Homebrew layout + /// (`/../lib/skit`). + private static let homebrewLibSkitSubpath = "lib/skit" + + /// Resolves a directory containing `libSyntaxKit.dylib` + swiftmodules. + /// + /// Tries each non-nil entry in `candidates` in order; if any non-nil + /// candidate is not a SyntaxKit lib dir, throws `CLIError`. If every + /// candidate is absent, falls back to bundle-relative paths derived + /// from `executableURL`: + /// - `/lib` (adjacent layout) + /// - `/../lib/skit` (Homebrew layout) + public func resolveLibPath( + candidates: [String?], + fileManager: FileManager = .default + ) throws -> String { + for candidate in candidates { + guard let candidate else { continue } + guard fileManager.isLibDir(candidate) else { + throw CLIError(message: "path does not look like a SyntaxKit lib dir: \(candidate)") + } + return candidate + } + + if let execURL = executableURL?.resolvingSymlinksInPath() { + let execDir = execURL.deletingLastPathComponent() + + let adjacent = execDir.appendingPathComponent(Self.libDirectoryName).path + if fileManager.isLibDir(adjacent) { + return adjacent + } + + let brewLayout = execDir.deletingLastPathComponent() + .appendingPathComponent(Self.homebrewLibSkitSubpath).path + if fileManager.isLibDir(brewLayout) { + return brewLayout + } + } + + throw CLIError( + message: """ + Could not locate SyntaxKit lib directory. Looked for: + 1. explicit candidates (none provided or all empty) + 2. /lib/ (not found) + 3. /../lib/skit/ (not found) + Run Scripts/build-skit.sh to produce a self-contained + release bundle under .build/skit-release/. + """ + ) + } +} diff --git a/Sources/SyntaxKit/Execution/CLIError.swift b/Sources/SyntaxKit/Execution/CLIError.swift new file mode 100644 index 00000000..f41c3a1f --- /dev/null +++ b/Sources/SyntaxKit/Execution/CLIError.swift @@ -0,0 +1,42 @@ +// +// CLIError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Throwable error wrapper for skit's user-facing diagnostics. The message +/// is printed verbatim — keep it actionable (path, hint, next step). +public struct CLIError: Error, CustomStringConvertible { + /// The user-facing diagnostic, printed verbatim. + public let message: String + /// The error description, identical to `message`. + public var description: String { message } + + /// Creates an error carrying a verbatim user-facing `message`. + public init(message: String) { + self.message = message + } +} diff --git a/Sources/SyntaxKit/Execution/CollectInputsError.swift b/Sources/SyntaxKit/Execution/CollectInputsError.swift new file mode 100644 index 00000000..bb4a36e9 --- /dev/null +++ b/Sources/SyntaxKit/Execution/CollectInputsError.swift @@ -0,0 +1,41 @@ +// +// CollectInputsError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Typed error surfaced by `FileManager.collectInputs(at:)`. The two cases map +/// to the two distinct ways the input walk can fail, so the caller can tell a +/// directory it couldn't enumerate apart from a file whose resource values it +/// couldn't read. +public enum CollectInputsError: Error { + /// The directory could not be enumerated (`FileManager.enumerator` returned + /// nil). Carries a user-facing `CLIError` describing the path. + case cliError(CLIError) + /// A file's resource values (isRegularFile/isDirectory) could not be read; + /// carries the underlying Foundation error. + case resourceValuesFailure(any Error) +} diff --git a/Sources/SyntaxKit/Execution/ContentHasher.swift b/Sources/SyntaxKit/Execution/ContentHasher.swift new file mode 100644 index 00000000..7411a734 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ContentHasher.swift @@ -0,0 +1,72 @@ +// +// ContentHasher.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Non-cryptographic 64-bit FNV-1a hasher used to derive content-addressed +/// cache keys. The cache keys aren't security-critical — there's no +/// adversary trying to forge a collision — so we don't need a cryptographic +/// hash. 64 bits of output gives ~10⁻⁹ collision probability at 10⁶ cache +/// entries, which is well past anything we'll see in practice. +/// +/// FNV-1a is deterministic across processes and platforms (unlike the Swift +/// stdlib `Hasher`, whose seed is randomized per-process) — that +/// determinism is what makes it usable as an on-disk cache key. It is the +/// default `ContentHashing` conformer used by `OutputCache`. +public struct ContentHasher: ContentHashing { + private static let offsetBasis: UInt64 = 0xcbf2_9ce4_8422_2325 + private static let prime: UInt64 = 0x0000_0100_0000_01b3 + + /// Width of the zero-padded lowercase-hex digest (64 bits → 16 hex chars). + private static let hexWidth = 16 + + private var state: UInt64 = ContentHasher.offsetBasis + + /// Creates a hasher seeded with the FNV-1a offset basis. + public init() {} + + /// Mixes `data` into the running hash state via FNV-1a (XOR each byte into + /// the state, then multiply by the FNV prime). + public mutating func update(data: Data) { + for byte in data { + state ^= UInt64(byte) + state &*= ContentHasher.prime + } + } + + /// Returns the full 64-bit hash as a 16-char lowercase-hex string suitable + /// for use as a directory name. Built with `String(_:radix:)` rather than + /// `String(format: "%016x", …)` because the `%x` specifier consumes a 32-bit + /// `unsigned int`, which would silently truncate the digest to its low 32 + /// bits and halve the entropy described above. + public func finalize() -> String { + let hex = String(state, radix: 16) + return String(repeating: "0", count: max(0, Self.hexWidth - hex.count)) + hex + } +} diff --git a/Sources/SyntaxKit/Execution/ContentHashing.swift b/Sources/SyntaxKit/Execution/ContentHashing.swift new file mode 100644 index 00000000..d451a6e1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ContentHashing.swift @@ -0,0 +1,52 @@ +// +// ContentHashing.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// An incremental hasher used to derive `OutputCache` keys. Abstracted so the +/// cache's hashing is pluggable: inject a different conformer via +/// `OutputCache.init(…, makeHasher:)` to swap the algorithm (e.g. a +/// cryptographic digest) without touching the cache. +/// +/// Two contractual requirements callers depend on, which the default +/// `ContentHasher` (FNV-1a) satisfies and a replacement must too: +/// - **Determinism across processes and platforms.** Keys are persisted to +/// disk and compared on later runs, so the same byte stream must always +/// produce the same digest. (The stdlib `Hasher` is unsuitable — it is +/// per-process seeded.) +/// - **A digest usable as a directory name.** `finalize()` returns a string +/// that is safe to use as a path component. +public protocol ContentHashing { + /// Creates an empty hasher, ready to accept `update(data:)` calls. + init() + /// Mixes `data`'s bytes into the running digest. Order-significant. + mutating func update(data: Data) + /// Returns the final digest as a filesystem-safe string. + func finalize() -> String +} diff --git a/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift b/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift new file mode 100644 index 00000000..674357c7 --- /dev/null +++ b/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift @@ -0,0 +1,53 @@ +// +// EnvironmentProvider+SyntaxKitCacheRoot.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Constants for `EnvironmentProvider.syntaxKitCacheRoot(default:)`. Nested +/// in a private enum to satisfy the "no globals" rule while letting the +/// protocol extension below reference them. +private enum SyntaxKitCacheRootConstants { + /// Environment variable pointing at the XDG cache root, if set. + static let xdgCacheHomeEnvKey = "XDG_CACHE_HOME" + /// Leaf directory appended to the XDG cache root for skit's caches. + static let cacheDirectoryName = "syntaxkit" +} + +extension EnvironmentProvider { + /// Root for all skit caches: `/syntaxkit` when that env + /// var is set and non-empty, otherwise `defaultRoot` (typically the + /// platform's home-relative cache dir). + internal func syntaxKitCacheRoot(default defaultRoot: URL) -> URL { + if let xdg = environment[SyntaxKitCacheRootConstants.xdgCacheHomeEnvKey], !xdg.isEmpty { + return URL(fileURLWithPath: xdg) + .appendingPathComponent(SyntaxKitCacheRootConstants.cacheDirectoryName) + } + return defaultRoot + } +} diff --git a/Sources/SyntaxKit/Execution/EnvironmentProvider.swift b/Sources/SyntaxKit/Execution/EnvironmentProvider.swift new file mode 100644 index 00000000..e0132f5e --- /dev/null +++ b/Sources/SyntaxKit/Execution/EnvironmentProvider.swift @@ -0,0 +1,44 @@ +// +// EnvironmentProvider.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// The slice of `ProcessInfo` `OutputCache` depends on. Abstracted into a +/// protocol so tests can inject a fixed environment without subclassing +/// `ProcessInfo` — `swift-corelibs-foundation` declares `ProcessInfo` as +/// `final` with `environment` on an extension, so subclass-and-override +/// doesn't compile on Linux/Windows. +public protocol EnvironmentProvider: Sendable { + /// Process environment variables. + var environment: [String: String] { get } + /// Identifier of the current process, used to namespace staging dirs. + var processIdentifier: Int32 { get } +} + +extension ProcessInfo: EnvironmentProvider {} diff --git a/Sources/SyntaxKit/Execution/FileEnumerationError.swift b/Sources/SyntaxKit/Execution/FileEnumerationError.swift new file mode 100644 index 00000000..2e68f19c --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileEnumerationError.swift @@ -0,0 +1,39 @@ +// +// FileEnumerationError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// A recursive regular-file enumeration failure, decoupled from any domain so +/// the caller maps it onto its own error type. +internal enum FileEnumerationError: Error { + /// The directory couldn't be enumerated at all. + case notEnumerable(URL) + /// A file's resource values couldn't be read mid-walk. + case resourceValuesUnavailable(URL, any Error) +} diff --git a/Sources/SyntaxKit/Execution/FileFingerprint.swift b/Sources/SyntaxKit/Execution/FileFingerprint.swift new file mode 100644 index 00000000..73ed3568 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileFingerprint.swift @@ -0,0 +1,39 @@ +// +// FileFingerprint.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Size + modification date of a file: the change-detection inputs behind a +/// content fingerprint/stamp. +internal struct FileFingerprint: Equatable { + /// File size in bytes (0 when the attribute is absent). + internal let size: Int + /// Last-modification date (Unix epoch when the attribute is absent). + internal let modificationDate: Date +} diff --git a/Sources/SyntaxKit/Execution/FileManager+Execution.swift b/Sources/SyntaxKit/Execution/FileManager+Execution.swift new file mode 100644 index 00000000..51b61bfb --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileManager+Execution.swift @@ -0,0 +1,91 @@ +// +// FileManager+Execution.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension FileManager { + /// Library product name whose platform-specific dylib marks a lib dir. + private static let syntaxKitProductName = "SyntaxKit" + /// File extension identifying SyntaxKit DSL input files. + private static let swiftFileExtension = "swift" + /// Filename prefix marking a source as "not an input" (skipped in batches). + private static let nonInputFilePrefix = "_" + + /// True if `path` is a directory containing `libSyntaxKit.{dylib,so}`. + internal func isLibDir(_ path: String) -> Bool { + guard pathKind(atPath: path) == .directory else { + return false + } + return pathKind(atPath: "\(path)/\(Self.syntaxKitProductName.dylibFilename)") != .missing + } + + /// `/` fingerprint of `libSyntaxKit.{dylib,so}` under + /// `libPath`, or nil if unreadable. Catches in-place rebuilds without a + /// version bump. + internal func libStamp(libPath: String) -> String? { + let dylib = "\(libPath)/\(Self.syntaxKitProductName.dylibFilename)" + return fingerprint(atPath: dylib).map { + "\($0.size)/\(Int($0.modificationDate.timeIntervalSince1970))" + } + } + + /// Returns every `.swift` file under `inputDir` (recursive), sorted, with + /// hidden files and files prefixed by `_` removed. The recursive walk and + /// sort come from `regularFiles(under:)`; this method adds only the SyntaxKit + /// input convention (`.swift` extension, skip the `_`-prefixed "not an input" + /// sources). + /// + /// Throws `CollectInputsError.cliError` when the directory can't be + /// enumerated, or `.resourceValuesFailure` when a file's resource values + /// can't be read — both are bulk failures with nothing per-file to report. + /// + /// `public` so callers (e.g. the skit CLI) can collect inputs explicitly and + /// feed the in-memory sources to `Runner.render(sources:)`. + public func collectInputs(at inputDir: URL) throws(CollectInputsError) -> [URL] { + let files: [URL] + do { + files = try regularFiles(under: inputDir) + } catch { + // `error` is typed `FileEnumerationError`; map each case onto the + // collect-specific error the caller already presents. + switch error { + case .notEnumerable(let directory): + throw .cliError(CLIError(message: "could not enumerate \(directory.path)")) + case .resourceValuesUnavailable(_, let underlying): + throw .resourceValuesFailure(underlying) + } + } + + return + files + .filter { $0.pathExtension == Self.swiftFileExtension } + .filter { !$0.lastPathComponent.hasPrefix(Self.nonInputFilePrefix) } + .map(\.standardizedFileURL) + } +} diff --git a/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift b/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift new file mode 100644 index 00000000..96af3362 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift @@ -0,0 +1,117 @@ +// +// FileManager+FileSystem.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension FileManager { + /// Classifies what exists at `path` in a single stat — `.missing`, `.file`, + /// or `.directory`. + internal func pathKind(atPath path: String) -> PathKind { + var isDirectory: ObjCBool = false + guard fileExists(atPath: path, isDirectory: &isDirectory) else { + return .missing + } + return isDirectory.boolValue ? .directory : .file + } + + /// Size + modification date of the file at `path`, or `nil` if its attributes + /// can't be read. Absent individual attributes default to `0` / the Unix + /// epoch so a readable item always yields a fingerprint. + internal func fingerprint(atPath path: String) -> FileFingerprint? { + guard let attributes = try? attributesOfItem(atPath: path) else { + return nil + } + let size = (attributes[.size] as? NSNumber)?.intValue ?? 0 + let modificationDate = + (attributes[.modificationDate] as? Date) + ?? Date(timeIntervalSince1970: 0) + return FileFingerprint(size: size, modificationDate: modificationDate) + } + + /// Every regular (non-directory) file under `directory`, recursively, with + /// hidden files skipped and the result sorted by path. Sorted because callers + /// generally want deterministic ordering, and doing it here keeps that + /// guarantee in one place. Unfiltered by extension — the caller decides what + /// counts as relevant. + internal func regularFiles(under directory: URL) throws(FileEnumerationError) -> [URL] { + guard + let enumerator = enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + throw .notEnumerable(directory) + } + + var result: [URL] = [] + for case let url as URL in enumerator { + // `.skipsHiddenFiles` keys off the dot-prefix convention on Unix but the + // `FILE_ATTRIBUTE_HIDDEN` attribute on Windows, so a dot-prefixed entry + // slips through there. Filter dot-prefixed components explicitly to keep + // the same hidden-file semantics on every platform. + if hasHiddenComponent(url, under: directory) { continue } + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + } catch { + throw .resourceValuesUnavailable(url, error) + } + if values.isDirectory == true { continue } + guard values.isRegularFile == true else { continue } + result.append(url) + } + return result.sorted { $0.path < $1.path } + } + + /// True when `url` lies under a dot-prefixed path component relative to + /// `directory`. Mirrors `.skipsHiddenFiles` on platforms (Windows) where that + /// option keys off the hidden *attribute* rather than the dot-prefix + /// convention — and matches Unix's behavior of not descending into hidden + /// directories by also excluding files nested under them. + private func hasHiddenComponent(_ url: URL, under directory: URL) -> Bool { + let base = directory.standardizedFileURL.pathComponents + let full = url.standardizedFileURL.pathComponents + guard full.count > base.count else { + return false + } + return full[base.count...].contains { $0.hasPrefix(".") } + } + + /// Writes `data` to `destination`, first creating any missing intermediate + /// directories. `public` so a caller writing a rendered batch (e.g. the skit + /// CLI) can mirror outputs into a destination tree. + public func writeData(_ data: Data, to destination: URL) throws { + try createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: destination) + } +} diff --git a/Sources/SyntaxKit/Execution/FileOutcome.swift b/Sources/SyntaxKit/Execution/FileOutcome.swift new file mode 100644 index 00000000..491111a0 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileOutcome.swift @@ -0,0 +1,76 @@ +// +// FileOutcome.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Per-input result of a batch render. `stdout` is the rendered Swift source +/// (empty on failure). `stderr` carries the (possibly path-rewritten) +/// diagnostics from the spawned `swift`; it may be present whether or not the +/// input succeeded (e.g. a successful render that emitted warnings). `result` +/// is `nil` when the input rendered successfully (so `stdout` is valid and the +/// caller may write it), and carries the `RunError` when the input could not be +/// rendered (`.renderFailed`/`.unexpected`). +/// +/// `Runner.render(sources:)` returns `[FileOutcome]`; per-input failures are +/// captured here, not thrown, so a single bad input doesn't tear the batch +/// down. The SDK does not write `stdout` anywhere — the caller owns that. +public struct FileOutcome: Sendable { + /// The input file this outcome describes. + public let input: URL + /// The rendered Swift source for this input; empty when `result` is non-nil. + public let stdout: Data + /// The (possibly path-rewritten) `swift` diagnostics for this input. + public let stderr: String + /// `nil` when the input rendered successfully; the `RunError` otherwise. + public let result: RunError? + + /// Creates an outcome for one rendered (or failed) input. + public init( + input: URL, + stdout: Data, + stderr: String, + result: RunError? + ) { + self.input = input + self.stdout = stdout + self.stderr = stderr + self.result = result + } +} + +extension Array where Element == FileOutcome { + /// Number of inputs whose `result` is non-nil — the signal the caller uses + /// to map a partially-failed batch to a non-zero exit (or whatever failure + /// semantics fit the host). + public var failureCount: Int { + reduce(into: 0) { count, outcome in + if outcome.result != nil { count += 1 } + } + } +} diff --git a/Sources/SyntaxKit/Execution/OutputCache.swift b/Sources/SyntaxKit/Execution/OutputCache.swift new file mode 100644 index 00000000..575dc2e4 --- /dev/null +++ b/Sources/SyntaxKit/Execution/OutputCache.swift @@ -0,0 +1,187 @@ +// +// OutputCache.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// On-disk cache of rendered skit output, content-keyed so a re-run on +/// unchanged input skips the `swift` spawn entirely. +/// +/// `Sendable`: the stored state is a `@Sendable` `FileManager` factory plus +/// an `EnvironmentProvider` and value types, so the single instance can be +/// shared safely across the concurrent `runOne` tasks in directory mode. The +/// default singletons used in production (and the typical test doubles) are +/// thread-safe for the operations we invoke. +public struct OutputCache: Sendable { + /// Bumped when the cache layout changes in a way that requires invalidation. + private static let schemaVersion = "v1" + + #if os(macOS) + /// macOS home-relative cache subpath (under `~`). + private static let macOSCacheSubpath = "Library/Caches/com.brightdigit.SyntaxKit" + #else + /// Linux home-relative cache subpath (under `~`). + private static let linuxCacheSubpath = ".cache/syntaxkit" + #endif + /// Leaf directory holding the rendered outputs within the cache root. + private static let outputsDirectoryName = "outputs" + /// Filename of the rendered Swift source stored per cache key. + private static let outputFileName = "output.swift" + /// Prefix for the per-writer staging directory used for atomic stores. + private static let stagingDirectoryPrefix = "tmp" + /// Environment-variable prefixes mixed into the cache key. + private static let skitEnvPrefix = "SKIT_" + private static let syntaxKitEnvPrefix = "SYNTAXKIT_" + + /// Home-relative cache root used when `XDG_CACHE_HOME` is unset: macOS + /// `~/Library/Caches/...`, else Linux `~/.cache/syntaxkit`. The home dir is + /// fixed for the process lifetime, so this is computed once. + private static let defaultCacheRoot: URL = { + let home = NSHomeDirectory() + #if os(macOS) + return URL(fileURLWithPath: home) + .appendingPathComponent(macOSCacheSubpath) + #else + return URL(fileURLWithPath: home).appendingPathComponent(linuxCacheSubpath) + #endif + }() + + /// `/outputs/`. Populated once at init; per-key + /// directories are derived from it on demand. + private let root: URL + private let fileManager: @Sendable () -> FileManager + private let environmentProvider: any EnvironmentProvider + /// Factory for the per-key hasher. Pluggable so the cache's hashing algorithm + /// can be swapped; defaults to `ContentHasher` (FNV-1a). A factory rather than + /// a stored instance because `key(forInput:libPath:)` needs a fresh, empty + /// hasher per call. + private let makeHasher: @Sendable () -> any ContentHashing + + /// Verbatim `swift --version` output captured once for the lifetime of + /// this cache, so per-input key derivation doesn't re-spawn `swift`. + /// nil if capture failed before construction. + private let swiftVersion: String? + + /// Creates a cache rooted under the SyntaxKit cache directory, keyed in part + /// by the captured `swiftVersion`. `fileManager`/`environmentProvider` are + /// injectable for testing; `makeHasher` is injectable to plug in a different + /// `ContentHashing` algorithm (defaults to `ContentHasher`). + public init( + swiftVersion: String?, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + environmentProvider: any EnvironmentProvider = ProcessInfo.processInfo, + makeHasher: @escaping @Sendable () -> any ContentHashing = { ContentHasher() } + ) { + self.root = environmentProvider.syntaxKitCacheRoot(default: Self.defaultCacheRoot) + .appendingPathComponent(Self.outputsDirectoryName) + self.swiftVersion = swiftVersion + self.fileManager = fileManager + self.environmentProvider = environmentProvider + self.makeHasher = makeHasher + } + + /// 64-bit content hash over (schema version, input source bytes, swift + /// version, libSyntaxKit stamp, sorted SKIT_*/SYNTAXKIT_* env vars). Any + /// change in these inputs produces a fresh key and forces a recompile. + /// See `ContentHasher` for the choice of FNV-1a over a cryptographic hash. + public func key(forInput source: String, libPath: String) -> String { + var hasher = makeHasher() + // Schema version: bump to invalidate every existing cache entry at once. + hasher.update(data: Data(Self.schemaVersion.utf8)) + // Input source bytes: the primary driver of the key. + hasher.update(data: Data(source.utf8)) + + // Toolchain version. Different `swift` builds emit different bytes for + // the same DSL input. + if let swiftVersion { + hasher.update(data: Data(swiftVersion.utf8)) + } + // libSyntaxKit stamp. A rebuilt dylib can change the rendered output + // even without a Swift-version bump. + if let stamp = fileManager().libStamp(libPath: libPath) { + hasher.update(data: Data(stamp.utf8)) + } + + // SKIT_*/SYNTAXKIT_* env vars. Sorted so the cache key is stable, and + // NUL-terminated so `"AB=" + "C"` doesn't collide with `"A=" + "BC"`. + let env = environmentProvider.environment + .filter { + $0.key.hasPrefix(Self.skitEnvPrefix) || $0.key.hasPrefix(Self.syntaxKitEnvPrefix) + } + .sorted { $0.key < $1.key } + for (key, value) in env { + hasher.update(data: Data("\(key)=\(value)\0".utf8)) + } + + return hasher.finalize() + } + + /// Returns the cached rendered output for `key`, or nil on miss. + public func lookup(key: String) -> Data? { + try? Data(contentsOf: directory(for: key).appendingPathComponent(Self.outputFileName)) + } + + /// Atomically stores `data` under `key`. Concurrent writers race via a + /// `tmp../` staging dir + rename; the loser drops their copy. + public func store(key: String, data: Data) throws { + let cacheRoot = directory(for: key) + let final = cacheRoot.appendingPathComponent(Self.outputFileName) + + // Ensure the parent of the cache key dir exists. The key dir itself is + // installed by the atomic rename below. + try fileManager().createDirectory( + at: cacheRoot.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Stage the payload in a per-pid + uuid sibling dir so it can be renamed + // into place as a single atomic step. + let staging = cacheRoot.deletingLastPathComponent() + .appendingPathComponent( + "\(Self.stagingDirectoryPrefix).\(environmentProvider.processIdentifier).\(UUID().uuidString)" + ) + try fileManager().createDirectory(at: staging, withIntermediateDirectories: true) + try data.write(to: staging.appendingPathComponent(Self.outputFileName)) + + // Atomic rename into the cache path. If a peer already populated this + // key, swallow the rename error and drop our staging copy. Re-throw only + // if the destination is still missing afterwards. + do { + try fileManager().moveItem(at: staging, to: cacheRoot) + } catch { + try? fileManager().removeItem(at: staging) + if fileManager().pathKind(atPath: final.path) == .missing { + throw error + } + } + } + + private func directory(for key: String) -> URL { + root.appendingPathComponent(key) + } +} diff --git a/Sources/SyntaxKit/Execution/PathKind.swift b/Sources/SyntaxKit/Execution/PathKind.swift new file mode 100644 index 00000000..75fb74c2 --- /dev/null +++ b/Sources/SyntaxKit/Execution/PathKind.swift @@ -0,0 +1,40 @@ +// +// PathKind.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// What exists at a filesystem path — the result of a single existence check, +/// replacing the `fileExists(atPath:isDirectory:)` + `ObjCBool` idiom with a +/// value callers can `switch` over. +internal enum PathKind: Equatable { + /// Nothing exists at the path. + case missing + /// A regular file (or any non-directory) exists at the path. + case file + /// A directory exists at the path. + case directory +} diff --git a/Sources/SyntaxKit/Execution/ProcessResult.swift b/Sources/SyntaxKit/Execution/ProcessResult.swift new file mode 100644 index 00000000..caab2357 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ProcessResult.swift @@ -0,0 +1,49 @@ +// +// ProcessResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Raw outcome of rendering one input — what `processFile` returns to its +/// caller. `exitCode == 0` indicates the spawned `swift` succeeded (or that +/// the output cache hit, which is treated identically). +public struct ProcessResult: Sendable { + /// Exit status of the spawned `swift`. `0` indicates success (or a cache hit). + public let exitCode: Int32 + /// Bytes the wrapped program emitted to standard output — the rendered source. + public let stdout: Data + /// Compiler diagnostics from the spawned `swift`. Empty when it was silent. + public let stderr: String + + /// Creates a result from a finished (or cache-served) render. + public init(exitCode: Int32, stdout: Data, stderr: String) { + self.exitCode = exitCode + self.stdout = stdout + self.stderr = stderr + } +} diff --git a/Sources/SyntaxKit/Execution/RenderInput.swift b/Sources/SyntaxKit/Execution/RenderInput.swift new file mode 100644 index 00000000..5df61e5d --- /dev/null +++ b/Sources/SyntaxKit/Execution/RenderInput.swift @@ -0,0 +1,50 @@ +// +// RenderInput.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// One in-memory input for a batch render: the source bytes paired with the +/// URL that identifies them. `Runner.render(sources:)` never opens `url` — it +/// is a *label*, used for `#sourceLocation` mapping and stderr path-rewriting, +/// and carried back on the matching `FileOutcome` so the caller can map the +/// rendered output to a destination (e.g. by rerooting `url` onto an output +/// tree). The caller is responsible for reading the file into `source`. +public struct RenderInput: Sendable { + /// The URL identifying this input. Used as a diagnostic label and as the + /// key the caller reroots when writing output; never opened by the SDK. + public let url: URL + /// The input's source bytes, already read into memory by the caller. + public let source: String + + /// Pairs an identifying `url` with its already-loaded `source`. + public init(url: URL, source: String) { + self.url = url + self.source = source + } +} diff --git a/Sources/SyntaxKit/Execution/RenderTaskResult.swift b/Sources/SyntaxKit/Execution/RenderTaskResult.swift new file mode 100644 index 00000000..dbdc4bf1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RenderTaskResult.swift @@ -0,0 +1,40 @@ +// +// RenderTaskResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Payload the per-input render `TaskGroup` yields back to `render(sources:)`. +/// Failures are captured (not thrown) so a single bad input doesn't tear down +/// the group; `processFile`'s heterogeneous Foundation/Subprocess throws are +/// normalized into `RunError` (typically `.unexpected`) by `runOne`. `Runner` +/// then folds each value into a public `FileOutcome`. +internal struct RenderTaskResult: Sendable { + internal let input: URL + internal let result: Result +} diff --git a/Sources/SyntaxKit/Execution/RunError.swift b/Sources/SyntaxKit/Execution/RunError.swift new file mode 100644 index 00000000..47110f0e --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunError.swift @@ -0,0 +1,48 @@ +// +// RunError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Typed error surfaced by `Runner`. It decouples the renderer from any +/// particular caller: `Runner` reports *what* went wrong, and the caller +/// (CLI, build plugin, in-process driver) decides how to present it. +public enum RunError: Error { + /// The input path was invalid — missing, or a directory given without an + /// output directory. + case invalidInput(String) + /// Single-file render: the spawned `swift` exited non-zero. Carries that + /// code (e.g. a compile failure, `124` on timeout, `128 + signal`) and + /// the (path-rewritten) stderr the toolchain emitted, so the caller can + /// surface diagnostics without having to fish them out elsewhere. Also + /// carries the session's `toolchainVerification`: when it isn't `.verified`, + /// the failure may stem from a Swift-toolchain mismatch the check couldn't + /// rule out, which the caller can hint at. + case renderFailed(exitCode: Int32, stderr: String, toolchain: ToolchainVerification) + /// A wrapped Foundation/Subprocess failure (file read/write, spawn error) + /// that has no dedicated mapping. + case unexpected(any Error) +} diff --git a/Sources/SyntaxKit/Execution/RunInput.swift b/Sources/SyntaxKit/Execution/RunInput.swift new file mode 100644 index 00000000..68608824 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunInput.swift @@ -0,0 +1,61 @@ +// +// RunInput.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Whether an input path resolves to a single `.swift` file or a directory +/// of them — the two modes a `Runner` caller dispatches into. Built by +/// `resolve(input:output:)`, which stats the path and enforces the per-mode +/// output rules so the caller can `switch` on a settled value instead of +/// juggling an `ObjCBool`. +public enum RunInput { + /// A single input file. `outputPath` is whatever the caller intends to + /// do with the rendered bytes (write to a file, ignore, etc.); `Runner` + /// itself does not act on it. + case singleFile(inputPath: String, outputPath: String?) + /// A directory of inputs mirrored into `outputDir` (always required). + case directory(inputDir: String, outputDir: String) + + /// Classifies `input` by stat: existing directory → `.directory`, + /// existing file → `.singleFile`. Throws `RunError.invalidInput` if the path + /// doesn't exist, or if a directory input wasn't given an explicit output. + public static func resolve(input: String, output: String?) throws(RunError) -> RunInput { + switch FileManager.default.pathKind(atPath: input) { + case .missing: + throw RunError.invalidInput("input does not exist: \(input)") + case .directory: + guard let output else { + throw RunError.invalidInput("directory inputs require -o ") + } + return .directory(inputDir: input, outputDir: output) + case .file: + return .singleFile(inputPath: input, outputPath: output) + } + } +} diff --git a/Sources/SyntaxKit/Execution/Runner+Directory.swift b/Sources/SyntaxKit/Execution/Runner+Directory.swift new file mode 100644 index 00000000..3d34b13b --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner+Directory.swift @@ -0,0 +1,126 @@ +// +// Runner+Directory.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension Runner { + /// Renders a batch of in-memory inputs concurrently (up to the active core + /// count) and returns a per-input `FileOutcome` carrying the rendered + /// `stdout`, diagnostics, and any `RunError`. The SDK reads nothing and + /// writes nothing — the caller supplies the sources (e.g. via + /// `FileManager.collectInputs` + reads) and writes each successful + /// `stdout` wherever it likes (e.g. by rerooting `outcome.input`). A failure + /// on one input does not affect its peers; inspect `failureCount` (and + /// per-outcome `stderr`) to decide presentation. An empty input set yields an + /// empty array. + public func render(sources: [RenderInput]) async -> [FileOutcome] { + guard !sources.isEmpty else { + return [] + } + + // Render every input with bounded concurrency, then fold each raw result + // into the public per-input outcome. + let renderResults = await processInputs(sources) + return renderResults.map(outcome(for:)) + } + + /// Folds one raw `RenderTaskResult` into a public `FileOutcome`: a zero-exit + /// render yields its `stdout` with `result == nil`; a non-zero exit becomes + /// `.renderFailed` (carrying the session toolchain); a captured throw is + /// surfaced as-is. No file IO — writing is the caller's job. + private func outcome(for taskResult: RenderTaskResult) -> FileOutcome { + switch taskResult.result { + case .success(let process) where process.exitCode == 0: + return FileOutcome( + input: taskResult.input, + stdout: process.stdout, + stderr: process.stderr, + result: nil + ) + case .success(let process): + return FileOutcome( + input: taskResult.input, + stdout: Data(), + stderr: process.stderr, + result: .renderFailed( + exitCode: process.exitCode, + stderr: process.stderr, + toolchain: toolchainVerification + ) + ) + case .failure(let error): + return FileOutcome(input: taskResult.input, stdout: Data(), stderr: "", result: error) + } + } + + /// Renders every input through `runOne` with bounded concurrency. The cap is + /// the active core count so a 200-file batch doesn't fork 200 simultaneous + /// `swift` processes: the group is seeded up to the cap, then refilled one + /// task per completion until the inputs are exhausted. + private func processInputs(_ inputs: [RenderInput]) async -> [RenderTaskResult] { + let maxConcurrent = max(1, ProcessInfo.processInfo.activeProcessorCount) + + var renderResults: [RenderTaskResult] = [] + renderResults.reserveCapacity(inputs.count) + var iterator = inputs.makeIterator() + + await withTaskGroup(of: RenderTaskResult.self) { group in + // Seed the group up to the concurrency cap… + for _ in 0.. RenderTaskResult { + do { + let result = try await processFile(source: input.source, originalPath: input.url.path) + return RenderTaskResult(input: input.url, result: .success(result)) + } catch let error as RunError { + return RenderTaskResult(input: input.url, result: .failure(error)) + } catch { + return RenderTaskResult(input: input.url, result: .failure(.unexpected(error))) + } + } +} diff --git a/Sources/SyntaxKit/Execution/Runner+Session.swift b/Sources/SyntaxKit/Execution/Runner+Session.swift new file mode 100644 index 00000000..c345a679 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner+Session.swift @@ -0,0 +1,103 @@ +// +// Runner+Session.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension Runner { + /// Brings up a render-ready `Runner`: resolves the libSyntaxKit directory + /// from `libCandidates` (falling back to bundle-relative layouts), optionally + /// gates on the bundle/local toolchain comparison, and wires up the output + /// cache. + /// + /// Platform-agnostic by construction — the two inputs that need a Subprocess + /// backend are injected by the caller: the already-captured `swiftVersion` + /// (`swift --version` output, or nil if capture failed) and the `run` closure + /// that actually spawns `swift` for one `SwiftInvocation`. + /// + /// - Throws: `RunnerSetupError` for a lookup or toolchain failure, so the caller + /// owns the presentation and exit mapping. + public init( + libCandidates: [String?], + swiftVersion: String?, + enforceToolchainCheck: Bool, + useCache: Bool, + timeoutSeconds: Int, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + run: @Sendable @escaping (SwiftInvocation) async throws -> SwiftRunOutcome + ) throws(RunnerSetupError) { + // 1. Resolve the libSyntaxKit bundle dir. Failure is fatal — there's no + // dylib + swiftmodules to link against without it. + let libPath: String + do { + libPath = try Bundle.main.resolveLibPath( + candidates: libCandidates, + fileManager: fileManager() + ) + } catch { + throw RunnerSetupError.libResolutionFailed(error) + } + + // 2. Compare the bundle's recorded `swift --version` against the local one. + // swiftmodules aren't reliably forward-compatible across compiler versions, + // so a mismatch is surfaced rather than letting the spawned `swift` emit a + // cryptic module-version diagnostic. A *mismatch* fails setup; anything else + // is remembered (no IO here — the SDK stays silent) so a later render + // failure can hint that an unverified toolchain may be the cause. + let verification: ToolchainVerification + if enforceToolchainCheck { + switch ToolchainCheckResult(libPath: libPath, swiftVersion: swiftVersion) { + case .match: + verification = .verified + case .stampMissing: + verification = .unverified + case .mismatch(let bundle, let local): + throw RunnerSetupError.toolchainMismatch(bundle: bundle, local: local) + } + } else { + verification = .notChecked + } + + // 3. Build the output cache (nil when disabled). The captured `swiftVersion` + // is bound in so per-input key derivation doesn't re-spawn `swift`. + let cache: OutputCache? = + useCache + ? OutputCache(swiftVersion: swiftVersion, fileManager: fileManager()) + : nil + + // 4. Delegate to the designated initializer, binding the spawn closure. + self.init( + libPath: libPath, + cache: cache, + timeoutSeconds: timeoutSeconds, + toolchainVerification: verification, + fileManager: fileManager(), + run: run + ) + } +} diff --git a/Sources/SyntaxKit/Execution/Runner.swift b/Sources/SyntaxKit/Execution/Runner.swift new file mode 100644 index 00000000..6da61709 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner.swift @@ -0,0 +1,225 @@ +// +// Runner.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +// Render lifecycle (per `Runner` call): +// 1. Bundle.main.resolveLibPath — find lib/ (explicit flag → env → adjacent → brew) +// 2. ToolchainCheckResult.init — compare bundle stamp to `swift --version` +// 3. Runner.render(source:) / .render(sources:) — single- or batch-input mode +// 4. processFile (per input) — cache lookup → wrap → spawn → cache store +// 5. wrap — hoist imports, wrap body in Group { … }, #sourceLocation +// 6. runSwift — spawn `swift` with timeout watchdog + +/// Renders SyntaxKit DSL inputs into Swift source. `Runner` is the SDK-shaped +/// entry point: methods return rendered data (`SingleFileRender`) or +/// structured per-file outcomes (`[FileOutcome]`) and throw typed +/// `RunError`s; callers — CLI, build plugin, in-process driver — decide what +/// to do with stdout, stderr, exit codes, and so on. +/// +/// Constructed once per render session; `Sendable` so a single value can be +/// shared across the concurrent per-input tasks in directory mode. +public struct Runner: Sendable { + /// Exit code returned when the spawned `swift` is killed by skit's timeout + /// watchdog. Matches POSIX `timeout(1)`. + private static let timeoutExitCode: Int32 = 124 + + /// Prefix for the per-invocation temp dir holding the wrapped input. + private static let tempDirectoryPrefix = "skit" + /// Filename of the wrapped Swift program spilled into the temp dir. + private static let wrappedInputFileName = "Input.wrapped.swift" + + /// Directory holding `libSyntaxKit.{dylib,so}` + swiftmodules; reused for + /// the spawned `swift`'s `-I`/`-L`/`-rpath` flags. + private let libPath: String + /// Output cache shared across every input, or nil under `--no-cache`. + private let cache: OutputCache? + /// Per-input watchdog in seconds; `0` opts out of the timeout race. + private let timeoutSeconds: Int + /// Backend that actually spawns `swift` for one `SwiftInvocation`. Injected + /// by the caller (skit supplies a Subprocess-based implementation). + private let run: @Sendable (SwiftInvocation) async throws -> SwiftRunOutcome + /// FileManager factory used for tmp-dir creation, batch enumeration, and + /// per-input writes. Injectable so tests can swap in a fake; defaults to + /// `.default` in production. + internal let fileManager: @Sendable () -> FileManager + /// Whether this session's bundle/local Swift toolchain was confirmed + /// compatible. Defaults to `.notChecked` for callers that construct a + /// `Runner` directly; the session initializer (`Runner+Session.swift`) sets + /// the real result. Carried onto `RunError.renderFailed`. + public let toolchainVerification: ToolchainVerification + + /// Creates a runner bound to a lib dir, optional output cache, per-input + /// timeout, and the backend closure that spawns `swift`. + public init( + libPath: String, + cache: OutputCache?, + timeoutSeconds: Int, + toolchainVerification: ToolchainVerification = .notChecked, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + run: @Sendable @escaping (SwiftInvocation) async throws -> SwiftRunOutcome + ) { + self.libPath = libPath + self.cache = cache + self.timeoutSeconds = timeoutSeconds + self.toolchainVerification = toolchainVerification + self.fileManager = fileManager + self.run = run + } + + // MARK: - Single-file mode + + /// Renders one in-memory input and returns the rendered bytes plus any + /// compiler diagnostics. The SDK reads no input and writes no output; + /// `originalPath` is only a diagnostic label (`#sourceLocation` + stderr + /// path-rewriting) — pass `nil` for an anonymous snippet. + /// On a non-zero subprocess exit, throws `RunError.renderFailed` carrying the + /// toolchain's diagnostic. Any Foundation/Subprocess failure (the internal + /// temp-wrapper write, spawn) is wrapped in `RunError.unexpected`. + public func render( + source: String, + originalPath: String? = nil + ) async throws(RunError) -> SingleFileRender { + let result: ProcessResult + do { + result = try await processFile(source: source, originalPath: originalPath) + } catch let error as RunError { + throw error + } catch { + throw RunError.unexpected(error) + } + // Non-zero subprocess exit is reported as a typed failure carrying both + // the code and the (path-rewritten) toolchain diagnostic. + guard result.exitCode == 0 else { + throw RunError.renderFailed( + exitCode: result.exitCode, + stderr: result.stderr, + toolchain: toolchainVerification + ) + } + return SingleFileRender(stdout: result.stdout, stderr: result.stderr) + } + + // MARK: - Per-file work + + /// The per-input render pipeline: consult the output cache → (on miss) wrap → + /// spawn `swift` → rewrite diagnostics → store in the cache. `source` comes + /// from the caller; `originalPath` is only a `#sourceLocation`/stderr label + /// (never opened). The temp wrapper is created in a per-run tmp dir and torn + /// down by `defer`. `internal` so `Runner+Directory.swift` can reuse it. + internal func processFile(source: String, originalPath: String?) async throws -> ProcessResult { + // Diagnostics label: standardize a given path, else a synthetic snippet name. + let absoluteInputPath = + originalPath.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + ?? "source.swift" + + // Compute the output cache key (nil under `--no-cache` or when the cache + // root couldn't be derived at startup). Mixes input bytes, toolchain + // version, libSyntaxKit stamp, and sorted SKIT_*/SYNTAXKIT_* env vars + // — see `OutputCache.key(forInput:libPath:)`. + let cacheKey: String? = cache?.key(forInput: source, libPath: libPath) + // Cache hit: skip the wrap+spawn entirely and return the stored output. + if let cache, let cacheKey, let cached = cache.lookup(key: cacheKey) { + return ProcessResult(exitCode: 0, stdout: cached, stderr: "") + } + + // Wrap the user's input into a complete Swift program that imports + // SyntaxKit, runs the body inside a Group { … } builder, and prints the + // result. See `WrappedSource` for the exact template. + let wrapped = WrappedSource(source: source, originalPath: absoluteInputPath).rendered + + // Spill the wrapped program to a per-invocation temp dir. The dir is + // cleaned up unconditionally so a failed spawn doesn't leak files. + let tmpDir = fileManager().temporaryDirectory + .appendingPathComponent("\(Self.tempDirectoryPrefix)-\(UUID().uuidString)") + try fileManager().createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? fileManager().removeItem(at: tmpDir) } + + let wrappedURL = tmpDir.appendingPathComponent(Self.wrappedInputFileName) + try wrapped.write(to: wrappedURL, atomically: true, encoding: .utf8) + + // Spawn `swift` on the wrapped file (with timeout watchdog). stdout is + // the rendered Swift source; stderr is compiler diagnostics, if any. + let raw = try await runSwift(wrappedPath: wrappedURL.path) + // #sourceLocation maps body diagnostics back to the input file. Errors in + // the preamble (lines outside the body) still reference the wrapper — + // rewrite literal occurrences of its path so users see something coherent. + let stderr = raw.stderr.replacingOccurrences( + of: wrappedURL.path, + with: absoluteInputPath + ) + + // Store on the way out. `try?` is deliberate: a cache write failure is + // not a render failure. The next run will simply miss and re-spawn. + if let cache, let cacheKey, raw.exitCode == 0 { + try? cache.store(key: cacheKey, data: raw.stdout) + } + + return ProcessResult(exitCode: raw.exitCode, stdout: raw.stdout, stderr: stderr) + } + + // MARK: - Spawning swift + + /// Spawns `swift` (via the injected `run` backend) on the wrapped input file. + /// When `timeoutSeconds > 0` the spawn races a sleep task in a throwing + /// task group; the loser is cancelled. On timeout, returns exit 124 with + /// a one-line stderr message — matching POSIX `timeout(1)`'s convention. + private func runSwift(wrappedPath: String) async throws -> ProcessResult { + let invocation = SwiftInvocation(libPath: libPath, wrappedPath: wrappedPath) + + // The actual backend call, wrapped in a closure so the task-group race + // below can hold a single Sendable reference to it. The backend already + // reports a `SwiftRunOutcome` (always `.completed` on a finished spawn). + let operation: @Sendable () async throws -> SwiftRunOutcome = { + try await self.run(invocation) + } + + // Race the invocation against a sleep watchdog; whichever finishes first + // wins, the other is cancelled. `timeoutSeconds <= 0` opts out of the + // race entirely (useful for debugging genuinely long codegen). + let outcome: SwiftRunOutcome + if timeoutSeconds <= 0 { + outcome = try await operation() + } else { + outcome = try await Task.timeout(seconds: timeoutSeconds, operation: operation) ?? .timedOut + } + + // Normalize both outcomes into a single ProcessResult shape. + switch outcome { + case .completed(let result): + return result + case .timedOut: + return ProcessResult( + exitCode: Self.timeoutExitCode, + stdout: Data(), + stderr: "skit: timed out after \(timeoutSeconds)s\n" + ) + } + } +} diff --git a/Sources/SyntaxKit/Execution/RunnerSetupError.swift b/Sources/SyntaxKit/Execution/RunnerSetupError.swift new file mode 100644 index 00000000..d8c09315 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunnerSetupError.swift @@ -0,0 +1,42 @@ +// +// RunnerSetupError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a render session couldn't be brought up. Decoupled from any caller: +/// the `Runner` session initializer reports *what* failed; the caller (CLI, +/// build plugin, in-process driver) decides how to present it and which exit +/// code to use. +public enum RunnerSetupError: Error { + /// The libSyntaxKit directory couldn't be resolved from the supplied + /// candidates or the bundle-relative fallbacks. Carries the underlying + /// `CLIError` describing the lookup. + case libResolutionFailed(any Error) + /// The bundle's recorded `swift --version` differs from the local one, + /// so spawning `swift` would hit a swiftmodule-version mismatch. + case toolchainMismatch(bundle: String, local: String) +} diff --git a/Sources/SyntaxKit/Execution/SingleFileRender.swift b/Sources/SyntaxKit/Execution/SingleFileRender.swift new file mode 100644 index 00000000..e6df4fe1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SingleFileRender.swift @@ -0,0 +1,48 @@ +// +// SingleFileRender.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Result of `Runner.renderFile`. Both fields may be populated on success — +/// the spawned `swift` can emit warnings to `stderr` alongside a valid +/// `stdout`. The caller decides where the bytes go (file, stdout, in-memory). +public struct SingleFileRender: Sendable { + /// Rendered Swift source produced by the wrapped program. + public let stdout: Data + /// Compiler diagnostics from the spawned `swift`, with the wrapper path + /// rewritten back to the original input. Empty when the toolchain was silent. + public let stderr: String + + /// Creates a single-file render result from the rendered `stdout` bytes and + /// any `stderr` diagnostics. + public init(stdout: Data, stderr: String) { + self.stdout = stdout + self.stderr = stderr + } +} diff --git a/Sources/SyntaxKit/Execution/String+DylibFilename.swift b/Sources/SyntaxKit/Execution/String+DylibFilename.swift new file mode 100644 index 00000000..861440d4 --- /dev/null +++ b/Sources/SyntaxKit/Execution/String+DylibFilename.swift @@ -0,0 +1,51 @@ +// +// String+DylibFilename.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension String { + /// Shared-library filename prefix (`lib`). + private static let dylibPrefix = "lib" + #if os(Linux) + /// Linux shared-library filename extension. + private static let linuxDylibExtension = ".so" + #else + /// macOS shared-library filename extension. + private static let macOSDylibExtension = ".dylib" + #endif + + /// Treats `self` as a Swift library product name and returns the + /// platform-specific shared-library filename (`libFoo.dylib` on macOS, + /// `libFoo.so` on Linux). + internal var dylibFilename: String { + #if os(Linux) + return "\(Self.dylibPrefix)\(self)\(Self.linuxDylibExtension)" + #else + return "\(Self.dylibPrefix)\(self)\(Self.macOSDylibExtension)" + #endif + } +} diff --git a/Sources/SyntaxKit/Execution/SwiftBackend.swift b/Sources/SyntaxKit/Execution/SwiftBackend.swift new file mode 100644 index 00000000..0a0a9171 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftBackend.swift @@ -0,0 +1,45 @@ +// +// SwiftBackend.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The two `swift`-toolchain operations a render session needs from a spawn +/// backend: capture the local toolchain version (feeds the toolchain check and +/// the cache key), and run `swift` for one `SwiftInvocation`. +/// +/// SyntaxKit defines the contract; a Subprocess-based conformance lives in the +/// `skit` CLI. `Sendable` so the spawn method can be captured in `Runner`'s +/// `@Sendable` run closure. +public protocol SwiftBackend: Sendable { + /// Verbatim `swift --version` output, or nil if the toolchain couldn't be + /// queried. + func captureSwiftVersion() async -> String? + + /// Spawns `swift` for `invocation` and normalizes the result into a + /// `SwiftRunOutcome`. + func runSwift(for invocation: SwiftInvocation) async throws -> SwiftRunOutcome +} diff --git a/Sources/SyntaxKit/Execution/SwiftInvocation.swift b/Sources/SyntaxKit/Execution/SwiftInvocation.swift new file mode 100644 index 00000000..45562c69 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftInvocation.swift @@ -0,0 +1,40 @@ +// +// SwiftInvocation.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The inputs needed to invoke `swift` on one wrapped DSL program: the lib +/// directory (for `-I`/`-L`/`-rpath`) and the path to the wrapped source. +/// `Runner` hands this to its `run` closure, which performs the actual spawn — +/// the seam that keeps the engine free of any Subprocess dependency. +public struct SwiftInvocation: Sendable { + /// Directory holding `libSyntaxKit` + swiftmodules, used for the spawned + /// `swift`'s `-I`/`-L`/`-rpath` flags. + public let libPath: String + /// Path to the wrapped DSL source the spawned `swift` should compile and run. + public let wrappedPath: String +} diff --git a/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift b/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift new file mode 100644 index 00000000..a7fb2c50 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift @@ -0,0 +1,36 @@ +// +// SwiftRunOutcome.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Either the spawned `swift` ran to completion (success or failure) or +/// the watchdog elapsed first. The completed payload is normalized to the +/// shape callers want regardless of platform. +public enum SwiftRunOutcome: Sendable { + case completed(ProcessResult) + case timedOut +} diff --git a/Sources/SyntaxKit/Execution/Task+Timeout.swift b/Sources/SyntaxKit/Execution/Task+Timeout.swift new file mode 100644 index 00000000..de27c77d --- /dev/null +++ b/Sources/SyntaxKit/Execution/Task+Timeout.swift @@ -0,0 +1,61 @@ +// +// Task+Timeout.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Task where Failure == any Error, Success: Sendable { + /// Runs `operation`, returning its value, or `nil` if `seconds` elapses + /// first. The operation and a sleep watchdog race in a throwing task group; + /// whichever finishes first wins and the loser is cancelled. + /// + /// Takes seconds rather than a `Duration` so the engine stays buildable on + /// the package's minimum deployment targets — `Duration` requires iOS 16 / + /// tvOS 16 / watchOS 9, whereas `Task.sleep(nanoseconds:)` back-deploys. + public static func timeout( + seconds: Int, + operation: @escaping @Sendable () async throws -> Success + ) async throws -> Success? { + try await withThrowingTaskGroup(of: Success?.self) { group in + group.addTask { try await operation() } + group.addTask { + // Bare `Task` here means `Task`, which has no + // `sleep`; spell out the never-returning task to reach it. + try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + return nil + } + for try await first in group { + group.cancelAll() + return first + } + // Unreachable: two child tasks were added above, so the group always + // yields at least one result before draining. Trip loudly in debug if + // that invariant is ever broken; fall back to `nil` in release. + assertionFailure("task group drained without yielding a result") + return nil + } + } +} diff --git a/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift b/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift new file mode 100644 index 00000000..30e9fdb3 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift @@ -0,0 +1,73 @@ +// +// ToolchainCheckResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Outcome of comparing the bundle's recorded build toolchain against the +/// local `swift --version`. The swiftmodule format isn't reliably +/// forward-compatible across Swift releases, so a mismatch is worth surfacing. +public enum ToolchainCheckResult { + /// Bundle stamp matches the local `swift --version` exactly. + case match + /// The toolchain check couldn't be performed: either `/swift-version.txt` + /// is missing (older bundle that predates the stamp) or the local + /// `swift --version` couldn't be captured. The caller proceeds; presentation + /// of the skipped-check note belongs to the call site, not this value type. + case stampMissing + /// The bundle stamp and the local `swift --version` differ. + case mismatch(bundle: String, local: String) + + /// Filename for the bundle's recorded build-toolchain version. + private static let toolchainStampFilename = "swift-version.txt" +} + +extension ToolchainCheckResult { + /// Compares `/swift-version.txt` to the caller-captured + /// `swiftVersion` string. The swiftmodule format isn't reliably + /// forward-compatible across even patch-level Swift releases (originating + /// bug: 6.3.0 → 6.3.2 rejected the swiftmodule), so the comparison is + /// exact-string after normalising trailing whitespace. + public init(libPath: String, swiftVersion: String?) { + let stampURL = URL(fileURLWithPath: libPath) + .appendingPathComponent(Self.toolchainStampFilename) + guard let stampData = try? Data(contentsOf: stampURL), + let stampRaw = String(data: stampData, encoding: .utf8) + else { + self = .stampMissing + return + } + guard let localRaw = swiftVersion else { + self = .stampMissing + return + } + let bundle = stampRaw.trimmingCharacters(in: .whitespacesAndNewlines) + let local = localRaw.trimmingCharacters(in: .whitespacesAndNewlines) + self = bundle == local ? .match : .mismatch(bundle: bundle, local: local) + } +} diff --git a/Sources/SyntaxKit/Execution/ToolchainVerification.swift b/Sources/SyntaxKit/Execution/ToolchainVerification.swift new file mode 100644 index 00000000..55df1fe9 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ToolchainVerification.swift @@ -0,0 +1,45 @@ +// +// ToolchainVerification.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Outcome of the bundle/local Swift-toolchain compatibility check performed +/// when a render session was created. Stored on the `Runner` and carried on +/// `RunError.renderFailed` so a caller can hint that an *unverified* toolchain +/// may be the real cause of a build error — swiftmodules aren't reliably +/// compatible across compiler versions. A confirmed *mismatch* never reaches +/// here; it fails session setup via `RunnerSetupError.toolchainMismatch`. +public enum ToolchainVerification: Sendable, Equatable { + /// The bundle's recorded `swift --version` matched the local one. + case verified + /// The caller opted out of the check (`enforceToolchainCheck == false`). + case notChecked + /// The check ran but couldn't compare versions — the bundle had no + /// toolchain stamp, or the local `swift --version` couldn't be captured — + /// so compatibility is unknown. + case unverified +} diff --git a/Sources/SyntaxKit/Execution/URL+Reroot.swift b/Sources/SyntaxKit/Execution/URL+Reroot.swift new file mode 100644 index 00000000..d3075488 --- /dev/null +++ b/Sources/SyntaxKit/Execution/URL+Reroot.swift @@ -0,0 +1,46 @@ +// +// URL+Reroot.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension URL { + /// Re-roots this URL from `base` onto `newBase`, preserving the relative + /// subpath — e.g. `/in/sub/a.swift` re-rooted from `/in` onto `/out` becomes + /// `/out/sub/a.swift`. Used to mirror an input tree into an output tree. + /// + /// - Precondition: `self` is located under `base` (its path is prefixed by + /// `base`'s). Callers that enumerate `base` satisfy this by construction. + /// + /// `public` so a caller mirroring a rendered batch (e.g. the skit CLI) can + /// map each `FileOutcome.input` onto its destination tree. + public func rerooted(from base: URL, onto newBase: URL) -> URL { + let relative = path.dropFirst(base.path.count + 1) + return newBase.appendingPathComponent(String(relative)) + } +} diff --git a/Sources/SyntaxKit/Execution/WrappedSource.swift b/Sources/SyntaxKit/Execution/WrappedSource.swift new file mode 100644 index 00000000..f99dbb2b --- /dev/null +++ b/Sources/SyntaxKit/Execution/WrappedSource.swift @@ -0,0 +1,122 @@ +// +// WrappedSource.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SwiftParser +import SwiftSyntax + +/// A SyntaxKit DSL input split into hoisted `import` declarations and a verbatim +/// body, ready to be `rendered` into a complete Swift program that runs SyntaxKit +/// on the body. +/// +/// The body is fenced in `#sourceLocation` directives so compiler diagnostics in +/// the body reference the original input file and line numbers. +public struct WrappedSource { + /// The original input path, used for the `#sourceLocation` directive. + private let originalPath: String + /// Top-level `import` declarations hoisted above the wrapper body, each already + /// trimmed of surrounding whitespace. + private let hoistedImports: [String] + /// The input source from the first non-import byte onward, verbatim. + private let body: String + /// The 1-based line number the body starts on in the original file. + private let firstBodyLine: Int + + /// A complete Swift program that imports SyntaxKit, runs the body inside a + /// `Group { … }` builder, and prints the generated code. + public var rendered: String { + // Render the hoisted-imports block. Trailing newline only if non-empty so + // the wrapper doesn't grow an extra blank line in the common no-imports + // case. + let hoistedBlock = hoistedImports.isEmpty ? "" : hoistedImports.joined(separator: "\n") + "\n" + + // #sourceLocation must use a forward-slash path; escape backslashes/quotes + // defensively even though macOS paths shouldn't contain them. + let escapedPath = + originalPath + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + // Build the final wrapper. Layout: SyntaxKit import → hoisted imports → + // Group { #sourceLocation(...) #sourceLocation() } → print. + return """ + import SyntaxKit + \(hoistedBlock) + let __skit_root = Group { + #sourceLocation(file: "\(escapedPath)", line: \(firstBodyLine)) + \(body) + #sourceLocation() + } + + print(__skit_root.generateCode()) + """ + } + + /// Parses `source`, hoisting leading `import` declarations and capturing the + /// remaining body along with the line it begins on. Everything before the first + /// non-import statement that *is* an import gets hoisted; anything before that + /// which is *not* an import stays in the body (e.g. a leading `// comment`). + public init(source: String, originalPath: String) { + self.originalPath = originalPath + + // Parse the input with SwiftSyntax. The location converter is needed to + // map the body's starting byte offset back to a 1-based line number for + // the `#sourceLocation` directive. + let tree = Parser.parse(source: source) + let locConverter = SourceLocationConverter(fileName: originalPath, tree: tree) + + // Scan top-level statements for hoistable imports. + var hoisted: [String] = [] + var firstBodyByte: AbsolutePosition? + + for item in tree.statements { + if let importDecl = item.item.as(ImportDeclSyntax.self), + firstBodyByte == nil + { + hoisted.append(importDecl.description.trimmingCharacters(in: .whitespacesAndNewlines)) + continue + } + firstBodyByte = item.position + break + } + + self.hoistedImports = hoisted + + // Compute the body slice (source from the first non-import byte onward) + // and the 1-based line number it lives on in the original file. + if let firstBodyByte { + let start = source.utf8.index(source.utf8.startIndex, offsetBy: firstBodyByte.utf8Offset) + self.body = String(source[start...]) + self.firstBodyLine = locConverter.location(for: firstBodyByte).line + } else { + self.body = "" + self.firstBodyLine = 1 + } + } +} diff --git a/Sources/skit/ProcessInfo+SkitLibPath.swift b/Sources/skit/ProcessInfo+SkitLibPath.swift new file mode 100644 index 00000000..c8beb1e1 --- /dev/null +++ b/Sources/skit/ProcessInfo+SkitLibPath.swift @@ -0,0 +1,41 @@ +// +// ProcessInfo+SkitLibPath.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension ProcessInfo { + /// Environment variable holding an override for the libSyntaxKit directory. + internal static let skitLibDirEnvironmentKey = "SKIT_LIB_DIR" + + /// The libSyntaxKit directory specified via `SKIT_LIB_DIR`, or `nil` when + /// the env var is unset or empty. + internal var skitLibPath: String? { + environment[Self.skitLibDirEnvironmentKey].flatMap { $0.isEmpty ? nil : $0 } + } +} diff --git a/Sources/skit/README.md b/Sources/skit/README.md new file mode 100644 index 00000000..23d5c518 --- /dev/null +++ b/Sources/skit/README.md @@ -0,0 +1,73 @@ +# skit + +A CLI for SyntaxKit. Two verbs: + +``` +skit run Input.swift # render a SyntaxKit DSL file into Swift source +skit run Input.swift -o Out.swift +skit run InputDir/ -o OutDir/ # walk **/*.swift and mirror rendered output +skit parse < Input.swift # parse Swift source into a JSON syntax tree +``` + +`run` is the default subcommand, so `skit Input.swift` is shorthand for `skit run Input.swift`. + +## Quick start + +```bash +# Build a self-contained release bundle (binary + dylib + swiftmodules). +Scripts/build-skit.sh +# → .build/skit-release/{skit, lib/} + +cat > /tmp/Person.swift <<'SWIFT' +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +SWIFT + +.build/skit-release/skit /tmp/Person.swift +``` + +The bundle is portable: `cp -r .build/skit-release ~/anywhere/` and `~/anywhere/skit-release/skit ` works zero-config. + +## Input file shape + +`skit run` wraps each input in an implicit `Group { … }` builder. Top-level expressions become the builder's content; `import` declarations at the top are hoisted into the wrapper. + +```swift +// Models.swift +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +``` + +What *won't* work inside the input: top-level `let`/`var` outside the builder DSL, `@main`, `print` (the wrapper adds its own). Result-builder closure rules apply. + +## Cache + +One layer, keyed on content + toolchain + dylib stamp + `SKIT_*`/`SYNTAXKIT_*` env vars. Lives under `~/Library/Caches/com.brightdigit.SyntaxKit/outputs//output.swift` on macOS, `$XDG_CACHE_HOME/syntaxkit/outputs//output.swift` (or `~/.cache/syntaxkit/outputs//output.swift`) on Linux. On hit, `skit` skips the `swift` spawn for an input entirely. + +Output cache hit is roughly ~0.14s on macOS (no spawn at all); cold miss matches the warm `swift` script-mode baseline (~0.5s). Force a miss with `--no-cache`. + +## Flag reference (`skit run`) + +| Flag | Default | Meaning | +| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-o, --output ` | stdout | Output file (single-file mode) or directory (folder mode). | +| `--lib ` | auto | Directory containing `libSyntaxKit.{dylib,so}` + module files. Search order when omitted: `$SKIT_LIB_DIR` → `/lib/` → `/../lib/skit/`. | +| `--no-cache` | (off) | Skip the output cache; always spawn `swift`. | +| `--timeout ` | `60` | Per-input timeout for the spawned `swift` (SIGTERM → 5s → SIGKILL). On expiry the file exits with code 124. Pass `0` to disable. | +| `--no-toolchain-check` | (off) | Skip the startup check that compares `lib/swift-version.txt` to `swift --version`. swiftmodules aren't reliably compatible across compiler versions; on mismatch skit refuses to spawn `swift` and points at the rebuild script. Auto-rebuild fallback tracked in [#157](https://github.com/brightdigit/SyntaxKit/issues/157). | + +## Platform notes + +- **macOS** — primary target. All build/release/test flows in `Scripts/`. +- **Linux** — verified on `swift:6.0-jammy/aarch64`. The Mach-O `install_name` step in `Scripts/build-skit.sh` is macOS-specific and skipped on Linux. +- **Windows** — not supported. + +Known Linux gotcha: `Foundation.Process.waitUntilExit()` hangs on already-exited children on `swift:6.0-jammy/aarch64`. `Runner.swift` works around it with `terminationHandler` + `DispatchSemaphore`. + +## Deeper dive + +- [`Docs/skit.md`](../../Docs/skit.md) — architecture, design decisions, trade-offs. diff --git a/Sources/skit/Render+Batch.swift b/Sources/skit/Render+Batch.swift new file mode 100644 index 00000000..4be2669a --- /dev/null +++ b/Sources/skit/Render+Batch.swift @@ -0,0 +1,154 @@ +// +// Render+Batch.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit + +extension Render { + /// Collects a directory's `.swift` inputs, reads them, renders them via the + /// filesystem-free `Runner.render(sources:)`, mirrors each rendered output + /// into `output`, prints per-input diagnostics (fenced when several files + /// emit them), prints a one-line summary, and maps any failures to + /// `ExitCode(1)` — Tuist-analog batch semantics. + internal static func renderBatch( + runner: Runner, + input: String, + output: String + ) async throws(RunCommandError) { + let inputURL = URL(fileURLWithPath: input).standardizedFileURL + let outputURL = URL(fileURLWithPath: output).standardizedFileURL + + let inputs: [URL] + do { + inputs = try FileManager.default.collectInputs(at: inputURL) + } catch { + throw RunCommandError(collectInputsError: error, input: input) + } + + if inputs.isEmpty { + FileHandle.standardError.write( + Data("\(Skit.Run.messagePrefix)no .swift inputs under \(input)\n".utf8) + ) + return + } + + // Read inputs into memory (read failures become per-file outcomes so a bad + // input doesn't abort the batch), render in-memory, then mirror each + // successful render into the output tree. + let (sources, readFailures) = loadSources(inputs) + let rendered = await runner.render(sources: sources) + let written = rendered.map { writeRendered($0, inputBase: inputURL, outputBase: outputURL) } + let outcomes = readFailures + written + + reportOutcomeDiagnostics(outcomes) + + FileHandle.standardError.write( + Data( + ("\(Skit.Run.messagePrefix)\(outcomes.count - outcomes.failureCount)" + + "/\(outcomes.count) succeeded\n").utf8 + ) + ) + + if outcomes.failureCount > 0 { + // Some inputs failed; if the toolchain couldn't be verified, hint once + // that a Swift-version mismatch may be behind the build errors above. + if let hint = RunCommandError.toolchainHint(runner.toolchainVerification) { + FileHandle.standardError.write(Data(hint.utf8)) + } + throw RunCommandError.failed + } + } + + /// Prints per-input diagnostics for a batch: each file's compiler stderr + /// (fenced with a `---- path ----` header), then for any non-`.renderFailed` + /// failure (whose stderr was just surfaced) a one-line `path: error`. Spawn/ + /// write failures carry their diagnostic in the error itself. + private static func reportOutcomeDiagnostics(_ outcomes: [FileOutcome]) { + for outcome in outcomes { + if !outcome.stderr.isEmpty { + FileHandle.standardError.write(Data("---- \(outcome.input.path) ----\n".utf8)) + FileHandle.standardError.write(Data(outcome.stderr.utf8)) + } + // .renderFailed already had its stderr surfaced above. Other failures + // (process spawn, write) carry the diagnostic in the error itself. + if let error = outcome.result { + if case .renderFailed = error { + continue + } + FileHandle.standardError.write(Data("\(outcome.input.path): \(error)\n".utf8)) + } + } + } + + /// Reads every input into a `RenderInput`. A read failure does not abort the + /// batch — it becomes a per-file `.unexpected` `FileOutcome`, returned + /// alongside the successfully-loaded sources. + private static func loadSources( + _ inputs: [URL] + ) -> (sources: [RenderInput], failures: [FileOutcome]) { + var sources: [RenderInput] = [] + var failures: [FileOutcome] = [] + for url in inputs { + do { + let source = try String(contentsOf: url, encoding: .utf8) + sources.append(RenderInput(url: url, source: source)) + } catch { + failures.append( + FileOutcome(input: url, stdout: Data(), stderr: "", result: .unexpected(error)) + ) + } + } + return (sources, failures) + } + + /// Mirrors a successful render's `stdout` to its destination under + /// `outputBase`, folding any write error into the returned outcome. Already- + /// failed outcomes pass through untouched. + private static func writeRendered( + _ outcome: FileOutcome, + inputBase: URL, + outputBase: URL + ) -> FileOutcome { + guard outcome.result == nil else { + return outcome + } + let destination = outcome.input.rerooted(from: inputBase, onto: outputBase) + do { + try FileManager.default.writeData(outcome.stdout, to: destination) + return outcome + } catch { + return FileOutcome( + input: outcome.input, + stdout: outcome.stdout, + stderr: outcome.stderr, + result: .unexpected(error) + ) + } + } +} diff --git a/Sources/skit/Render.swift b/Sources/skit/Render.swift new file mode 100644 index 00000000..f224ea9d --- /dev/null +++ b/Sources/skit/Render.swift @@ -0,0 +1,114 @@ +// +// Render.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit + +/// Render-side IO for the `skit run` subcommand: classifies the input, calls +/// the silent `Runner` SDK, and owns all stdout/stderr presentation and file +/// writes. Hosted in an `enum` namespace because none of these helpers use +/// instance state from `Skit.Run` — extracted from `Skit.Run` so the +/// subcommand struct stays focused on argument parsing. +internal enum Render { + /// Classifies `input` and dispatches to the matching `Runner` SDK method. + /// The SDK layer is silent — this function (and its helpers) own all + /// stdout/stderr presentation and file IO, and translate `RunError`/batch + /// failures into the typed `RunCommandError` that `Skit.Run.run()` maps to + /// a process exit. Success-path output is still written here. + internal static func render(using runner: Runner, input: String, output: String?) + async throws(RunCommandError) + { + let resolved: RunInput + do { + resolved = try RunInput.resolve(input: input, output: output) + } catch .invalidInput(let message) { + throw RunCommandError.usage(message) + } catch { + // RunInput.resolve only throws .invalidInput; defensive. + throw RunCommandError.failed + } + + switch resolved { + case .singleFile(let inputPath, let outputPath): + try await renderSingle(runner: runner, input: inputPath, output: outputPath) + case .directory(let inputDir, let outputDir): + try await renderBatch(runner: runner, input: inputDir, output: outputDir) + } + } + + /// Reads `input`, renders it via the filesystem-free `Runner.render(source:)`, + /// prints any compiler stderr, and writes the rendered Swift source either to + /// `output` or to stdout. The runner reads and writes nothing — that's the + /// CLI's job. + private static func renderSingle( + runner: Runner, + input: String, + output: String? + ) async throws(RunCommandError) { + let inputURL = URL(fileURLWithPath: input).standardizedFileURL + let source: String + do { + source = try String(contentsOf: inputURL, encoding: .utf8) + } catch { + throw RunCommandError.unexpected(error) + } + + let rendered: SingleFileRender + do { + rendered = try await runner.render(source: source, originalPath: inputURL.path) + } catch { + // `error` is typed `RunError` (render is `throws(RunError)`); the + // initializer's switch is exhaustive, so this single catch is total. + throw RunCommandError(renderFileError: error) + } + + try emit(rendered, to: output) + } + + /// Prints any diagnostics, then writes the rendered source to `output` (or + /// stdout when `output` is nil). A write failure has no diagnostic of its own, + /// so the underlying error is surfaced (ArgumentParser prints it, exit 1). + private static func emit( + _ rendered: SingleFileRender, + to output: String? + ) throws(RunCommandError) { + if !rendered.stderr.isEmpty { + FileHandle.standardError.write(Data(rendered.stderr.utf8)) + } + guard let output else { + FileHandle.standardOutput.write(rendered.stdout) + return + } + do { + try rendered.stdout.write(to: URL(fileURLWithPath: output)) + } catch { + throw RunCommandError.unexpected(error) + } + } +} diff --git a/Sources/skit/RunCommandError.swift b/Sources/skit/RunCommandError.swift new file mode 100644 index 00000000..55db1f92 --- /dev/null +++ b/Sources/skit/RunCommandError.swift @@ -0,0 +1,180 @@ +// +// RunCommandError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxKit + +/// Typed failures the `run` pipeline can produce. The render steps just +/// `throw` the case that describes *what* failed; `run()` catches these in +/// one place and converts each to its stderr diagnostic + process exit, so +/// the steps stay free of presentation/exit logic. +internal enum RunCommandError: Error { + /// A usage error (bad input path, or a directory given without `-o`). + /// Surfaced as an ArgumentParser `ValidationError` (exit 64), which prints + /// the message and the command's usage. + case usage(String) + /// The libSyntaxKit directory couldn't be resolved. Exit 2. + case libResolutionFailed(any Error) + /// The bundle's recorded `swift --version` differs from the local one. + /// Exit 2. + case toolchainMismatch(bundle: String, local: String) + /// The spawned `swift` exited non-zero (compile failure, `124` on timeout, + /// `128 + signal`). Carries that code + the toolchain stderr; the code is + /// passed through as the process exit. Also carries the session's + /// `toolchainVerification` so an unverified toolchain can be hinted as a + /// possible cause alongside the diagnostics. + case renderFailed(exitCode: Int32, stderr: String, toolchain: ToolchainVerification) + /// A directory batch couldn't be walked. Exit 1. + case directoryWalkFailed(input: String, underlying: any Error) + /// A render/batch failure whose diagnostics were already surfaced (per-input + /// output, a printed summary, or none). Exit 1 with nothing further to say. + case failed + /// A wrapped Foundation/Subprocess failure with no dedicated mapping; the + /// underlying error is rethrown so ArgumentParser prints it (exit 1). + case unexpected(any Error) + /// `run` was invoked on a platform without a Subprocess backend. Exit 1. + case unsupportedPlatform + + /// stderr text to emit before exiting, or nil. `.usage` is printed by + /// `ValidationError`; `.failed`/`.unexpected` print nothing here (their + /// diagnostics were already surfaced, or ArgumentParser prints them). + internal var diagnostic: String? { + switch self { + case .usage, .failed, .unexpected: + return nil + case .libResolutionFailed(let error): + return "\(error)\n" + case .toolchainMismatch(let bundle, let local): + return Self.toolchainMismatchMessage(bundle: bundle, local: local) + case .renderFailed(_, let stderr, let toolchain): + let parts = [stderr.isEmpty ? nil : stderr, Self.toolchainHint(toolchain)] + .compactMap { $0 } + return parts.isEmpty ? nil : parts.joined() + case .directoryWalkFailed(let input, let underlying): + return "\(Skit.Run.messagePrefix)failed to walk \(input): \(underlying)\n" + case .unsupportedPlatform: + return "\(Skit.Run.messagePrefix)run is not supported on this platform " + + "(no Subprocess backend).\n" + } + } + + /// The terminal error ArgumentParser acts on: a `ValidationError` (usage), + /// an `ExitCode`, or the wrapped underlying error. + internal var terminalError: any Error { + switch self { + case .usage(let message): + return ValidationError(message) + case .libResolutionFailed, .toolchainMismatch: + return ExitCode(2) + case .renderFailed(let exitCode, _, _): + return ExitCode(exitCode) + case .directoryWalkFailed, .failed, .unsupportedPlatform: + return ExitCode(1) + case .unexpected(let underlying): + return underlying + } + } + + /// Maps a SyntaxKit session-setup failure onto the CLI's exit policy. + internal init(_ error: RunnerSetupError) { + switch error { + case .libResolutionFailed(let underlying): + self = .libResolutionFailed(underlying) + case .toolchainMismatch(let bundle, let local): + self = .toolchainMismatch(bundle: bundle, local: local) + } + } + + /// Maps the `RunError` from `Runner.renderFile` onto the CLI's exit policy. + /// `renderFile`'s error is exhaustive over `RunError`, so every case has a + /// direct translation. + internal init(renderFileError error: RunError) { + switch error { + case .invalidInput(let message): + self = .usage(message) + case .renderFailed(let exitCode, let stderr, let toolchain): + self = .renderFailed(exitCode: exitCode, stderr: stderr, toolchain: toolchain) + case .unexpected(let underlying): + self = .unexpected(underlying) + } + } + + /// Maps the `CollectInputsError` from `FileManager.collectInputs` onto the + /// CLI's exit policy. Both cases are bulk directory-walk failures, carrying + /// `input` for the "failed to walk" framing the CLI prints. + internal init(collectInputsError error: CollectInputsError, input: String) { + switch error { + case .cliError(let underlying): + self = .directoryWalkFailed(input: input, underlying: underlying) + case .resourceValuesFailure(let underlying): + self = .directoryWalkFailed(input: input, underlying: underlying) + } + } + + /// A one-line note appended to a render failure when the bundle/local Swift + /// toolchain wasn't confirmed compatible — so a module-version error reads + /// as a possible toolchain mismatch rather than a mysterious build failure. + /// `nil` when the toolchain was verified (nothing to add). `internal` so the + /// directory-batch path can surface the same note once. + internal static func toolchainHint(_ verification: ToolchainVerification) -> String? { + switch verification { + case .verified: + return nil + case .notChecked: + return "\(Skit.Run.messagePrefix)note: the bundle/local Swift-toolchain check was skipped " + + "(--\(Skit.Run.noToolchainCheckFlagName)); if this is a module-version error, a " + + "toolchain mismatch may be the cause.\n" + case .unverified: + return + "\(Skit.Run.messagePrefix)note: couldn't verify the bundle's Swift toolchain against " + + "your local `swift` (no toolchain stamp, or version capture failed); if this is a " + + "module-version error, a toolchain mismatch may be the cause.\n" + } + } + + /// Human-readable error explaining why the bundle's recorded + /// `swift --version` differs from the local one, and how to recover. + private static func toolchainMismatchMessage(bundle: String, local: String) -> String { + """ + \(Skit.Run.messagePrefix)toolchain mismatch + bundle: \(bundle) + local: \(local) + The bundle's libSyntaxKit was built against a different `swift` than the + one on your PATH. Swift swiftmodules aren't reliably compatible across + versions, so spawning `swift` would fail with a cryptic module-version + diagnostic. + + Rebuild the bundle with: + \(Skit.Run.buildReleaseScriptPath) + Or pass --\(Skit.Run.noToolchainCheckFlagName) to try anyway. + + """ + } +} diff --git a/Sources/skit/Skit+Parse.swift b/Sources/skit/Skit+Parse.swift new file mode 100644 index 00000000..f54328b0 --- /dev/null +++ b/Sources/skit/Skit+Parse.swift @@ -0,0 +1,54 @@ +// +// Skit+Parse.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxParser + +extension Skit { + internal struct Parse: ParsableCommand { + /// The subcommand name as invoked on the command line. + internal static let commandName = "parse" + + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Parse Swift source on stdin into a JSON syntax tree on stdout." + ) + + internal func run() throws { + let code = + String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + let treeNodes = SyntaxParser.parse(code: code) + let encoder = JSONEncoder() + let data = try encoder.encode(treeNodes) + let json = String(decoding: data, as: UTF8.self) + print(json) + } + } +} diff --git a/Sources/skit/Skit+Run.swift b/Sources/skit/Skit+Run.swift new file mode 100644 index 00000000..4a62bfc4 --- /dev/null +++ b/Sources/skit/Skit+Run.swift @@ -0,0 +1,172 @@ +// +// Skit+Run.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxKit + +extension Skit { + /// Render one or more SyntaxKit DSL files into Swift source. + /// + /// `Run` is the default subcommand. It accepts either a single `.swift` + /// file or a directory of `.swift` files; in directory mode the rendered + /// output is written into a mirrored tree under `-o`. The actual work is + /// delegated to a `Runner` value (`Runner.swift`). + internal struct Run: AsyncParsableCommand { + /// The subcommand name as invoked on the command line. + internal static let commandName = "run" + + /// User-facing option/flag names (the `--long` CLI surface). + internal static let outputOptionName = "output" + internal static let libOptionName = "lib" + internal static let noCacheFlagName = "no-cache" + internal static let timeoutOptionName = "timeout" + internal static let noToolchainCheckFlagName = "no-toolchain-check" + + /// Prefix for skit's own diagnostics written to stderr. + internal static let messagePrefix = "skit: " + + /// Argument passed to `swift` to capture the toolchain version banner. + internal static let versionFlag = "--version" + + /// Path to the script that rebuilds the self-contained release bundle. + internal static let buildReleaseScriptPath = "Scripts/build-skit.sh" + + /// Largest timeout we can safely convert to nanoseconds without overflowing + /// the `UInt64` multiplication in `Task.timeout` (`UInt64(seconds) * 1e9`). + internal static let maxTimeoutSeconds = Int(UInt64.max / 1_000_000_000) + + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Render SyntaxKit DSL input(s) into Swift source." + ) + + @Argument(help: "Path to a .swift input file or a directory of inputs.") + internal var input: String + + @Option( + name: [.short, .customLong(Run.outputOptionName)], + help: "Output file (single-file mode) or directory (folder mode)." + ) + internal var output: String? + + @Option( + name: .customLong(Run.libOptionName), + help: "Directory containing libSyntaxKit.dylib + module files." + ) + internal var libPath: String? + + @Flag( + name: .customLong(Run.noCacheFlagName), + help: "Skip the rendered-output cache (always run swift)." + ) + internal var noCache: Bool = false + + @Option( + name: .customLong(Run.timeoutOptionName), + help: "Per-input timeout for the spawned `swift` in seconds (0 disables)." + ) + internal var timeoutSeconds: Int = 60 + + @Flag( + name: .customLong(Run.noToolchainCheckFlagName), + help: "Skip the bundle/local Swift-toolchain comparison." + ) + internal var noToolchainCheck: Bool = false + + internal func validate() throws { + guard timeoutSeconds >= 0 else { + throw ValidationError( + "--\(Self.timeoutOptionName) expects a non-negative integer (seconds), " + + "got: \(timeoutSeconds)" + ) + } + // Upper bound: the value is later multiplied by 1_000_000_000 into a + // `UInt64` of nanoseconds, which overflows silently past this point. + guard timeoutSeconds <= Self.maxTimeoutSeconds else { + throw ValidationError( + "--\(Self.timeoutOptionName) is too large (max \(Self.maxTimeoutSeconds) seconds), " + + "got: \(timeoutSeconds)" + ) + } + } + + internal func execute() async throws(RunCommandError) { + // The spawn backend is nil only where there's no Subprocess backend + // (Windows, embedded) — there `run` can't spawn `swift`/`swiftc`. + guard let backend = Self.swiftBackend else { + throw RunCommandError.unsupportedPlatform + } + + // Capture the two backend-dependent inputs to the otherwise + // platform-agnostic session setup: the local `swift --version` (feeds + // the toolchain check + the cache key, spawned exactly once per run) + // and the SKIT_LIB_DIR override. + let swiftVersion = await backend.captureSwiftVersion() + let envLibPath = ProcessInfo.processInfo.skitLibPath + + // Resolve lib dir → toolchain-gate → cache → assemble Runner. That + // orchestration lives in SyntaxKit (the `Runner` session initializer); + // skit injects the captured `swiftVersion` and the backend's spawn + // method, and maps the typed setup failure onto the CLI's exit policy. + let runner: Runner + do { + runner = try Runner( + libCandidates: [self.libPath, envLibPath], + swiftVersion: swiftVersion, + enforceToolchainCheck: !noToolchainCheck, + useCache: !noCache, + timeoutSeconds: timeoutSeconds + ) { try await backend.runSwift(for: $0) } + } catch { + throw RunCommandError(error) + } + + // Hand the input off to the runner: `Render` owns presentation and the + // single-file/directory dispatch, translating render failures into + // `RunCommandError` for the outer catch to map. + try await Render.render(using: runner, input: input, output: output) + } + + /// Renders the input(s), with a single seam between the pipeline and the + /// process: every step `throw`s a typed `RunCommandError`, and the outer catch + /// maps that to its stderr diagnostic + terminal `ExitCode`/`ValidationError`. + /// Any non-`RunCommandError` propagates to ArgumentParser unchanged. + internal func run() async throws { + do { + try await self.execute() + } catch { + if let diagnostic = error.diagnostic { + FileHandle.standardError.write(Data(diagnostic.utf8)) + } + throw error.terminalError + } + } + } +} diff --git a/Sources/skit/Skit.swift b/Sources/skit/Skit.swift index 1066b5e2..d500b9de 100644 --- a/Sources/skit/Skit.swift +++ b/Sources/skit/Skit.swift @@ -27,24 +27,31 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import SyntaxParser +import ArgumentParser +/// The `skit` CLI entry point. +/// +/// `Skit` itself is just an ArgumentParser shell that wires up two subcommands: +/// `Run` (the default, rendering SyntaxKit DSL into Swift source) and `Parse` +/// (the inverse, reading Swift source on stdin and emitting JSON). Their bodies +/// live in `Skit+Run.swift` and `Skit+Parse.swift` respectively. @main -internal enum Skit { - internal static func main() throws { - // Read Swift code from stdin - let code = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" +internal struct Skit: AsyncParsableCommand { + /// The top-level command name as invoked on the command line. + internal static let commandName = "skit" - // Parse the code using SyntaxKit - let treeNodes = SyntaxParser.parse(code: code) + /// Name of the `swift` executable resolved on `PATH`. Shared by the + /// toolchain-version capture and the Subprocess `swift` configuration. + internal static let swiftExecutableName = "swift" - // Convert to JSON for output - let encoder = JSONEncoder() - let data = try encoder.encode(treeNodes) - let json = String(decoding: data, as: UTF8.self) + /// Name of the `swiftc` compiler resolved on `PATH`. Used to compile a + /// wrapped DSL program into a temporary executable before running it. + internal static let swiftcExecutableName = "swiftc" - // Output the JSON - print(json) - } + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Render SyntaxKit DSL into Swift source, or parse Swift into JSON.", + subcommands: [Run.self, Parse.self], + defaultSubcommand: Run.self + ) } diff --git a/Sources/skit/Subprocess.Configuration+Swift.swift b/Sources/skit/Subprocess.Configuration+Swift.swift new file mode 100644 index 00000000..403b0dfb --- /dev/null +++ b/Sources/skit/Subprocess.Configuration+Swift.swift @@ -0,0 +1,148 @@ +// +// Subprocess.Configuration+Swift.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Foundation + import Subprocess + import SyntaxKit + + // System (FilePath) ships in the Apple SDK on Darwin; non-Apple platforms + // need swift-system's `SystemPackage` product to get the same `FilePath`. + #if canImport(System) + import System + #else + import SystemPackage + #endif + + extension Subprocess.Configuration { + /// Bounded output capacity for the spawned process (16 MiB stdout / 1 MiB + /// stderr). Output above this is exotic; we'd surface a clear SubprocessError + /// rather than silently truncate. + private static let stdoutLimitBytes = 16 * 1_024 * 1_024 + private static let stderrLimitBytes = 1 * 1_024 * 1_024 + + /// Subdirectory of the lib dir holding the SwiftSyntax CShims headers. + private static let cShimsIncludeSuffix = "_SwiftSyntaxCShims-include" + + /// Filename prefix for the per-render temporary executable. + private static let renderExecutablePrefix = "skit-render-" + + /// swiftc flags used to build the compile invocation. + private static let flagSuppressWarnings = "-suppress-warnings" + private static let flagInclude = "-I" + private static let flagLibrarySearchPath = "-L" + private static let flagLinkSyntaxKit = "-lSyntaxKit" + private static let flagPassToClang = "-Xcc" + private static let flagPassToLinker = "-Xlinker" + private static let flagRPath = "-rpath" + private static let flagOutput = "-o" + + /// A configuration that compiles `wrappedPath` into an executable at + /// `outputPath`, linked against `libSyntaxKit` in `libPath`. + /// + /// Builds the full flag set: link against libSyntaxKit, include the CShims + /// headers, and bake an rpath so the produced binary loads the dylib at + /// runtime. `swiftc` is used rather than the `swift` interpreter because the + /// interpreter's JIT does not load the dynamic libSyntaxKit's symbols. The + /// executable is resolved by name on `PATH`. + internal static func compileSwift( + libPath: String, + wrappedPath: String, + outputPath: String + ) -> Self { + let cShimsInclude = "\(libPath)/\(cShimsIncludeSuffix)" + let arguments: [String] = [ + flagSuppressWarnings, + flagInclude, libPath, + flagLibrarySearchPath, libPath, + flagLinkSyntaxKit, + flagPassToClang, flagInclude, flagPassToClang, cShimsInclude, + flagPassToLinker, flagRPath, flagPassToLinker, libPath, + flagOutput, outputPath, + wrappedPath, + ] + return Self(executable: .name(Skit.swiftcExecutableName), arguments: Arguments(arguments)) + } + + /// Renders the `invocation` by compiling the wrapped DSL program with + /// `swiftc` and then running the produced executable, whose stdout is the + /// generated Swift source. This is the Subprocess backend skit hands to + /// `Runner` as its `run` closure — the one seam between the (platform- + /// agnostic) engine in SyntaxKit and the Subprocess implementation. + /// + /// A compile failure short-circuits and is reported with the compiler's + /// exit code + stderr (the render layer maps any non-zero exit to a render + /// failure). A successful compile is followed by running the binary and + /// reporting its result. A completed spawn always reports `.completed`; the + /// timeout race that can produce `.timedOut` lives in `Runner`. + internal static func runSwift( + for invocation: SyntaxKit.SwiftInvocation + ) async throws -> SwiftRunOutcome { + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(renderExecutablePrefix)\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: outputURL) } + + // 1. Compile the wrapped program to a temporary executable. + let compile = try await Subprocess.run( + .compileSwift( + libPath: invocation.libPath, + wrappedPath: invocation.wrappedPath, + outputPath: outputURL.path + ), + output: .discarded, + error: .string(limit: stderrLimitBytes) + ) + guard compile.terminationStatus.exitCode == 0 else { + return .completed( + ProcessResult( + exitCode: compile.terminationStatus.exitCode, + stdout: Data(), + stderr: compile.standardError ?? "" + ) + ) + } + + // 2. Run the compiled program; its stdout is the rendered Swift source. + let render = try await Subprocess.run( + .path(FilePath(outputURL.path)), + output: .string(limit: stdoutLimitBytes), + error: .string(limit: stderrLimitBytes) + ) + return .completed( + ProcessResult( + exitCode: render.terminationStatus.exitCode, + stdout: Data((render.standardOutput ?? "").utf8), + stderr: render.standardError ?? "" + ) + ) + } + } + +#endif diff --git a/Sources/skit/SubprocessBackend.swift b/Sources/skit/SubprocessBackend.swift new file mode 100644 index 00000000..6ad70930 --- /dev/null +++ b/Sources/skit/SubprocessBackend.swift @@ -0,0 +1,71 @@ +// +// SubprocessBackend.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SyntaxKit + +#if canImport(Subprocess) + + import Subprocess + + /// Subprocess-backed `SwiftBackend`: the real `swift`-spawning implementation + /// skit hands to the render session. The one seam between the platform- + /// agnostic engine in SyntaxKit and the Subprocess implementation. + internal struct SubprocessBackend: SwiftBackend { + /// Verbatim `swift --version` output, or nil on spawn failure. Capped at 4 KiB. + internal func captureSwiftVersion() async -> String? { + let result = try? await Subprocess.run( + .name(Skit.swiftExecutableName), + arguments: [Skit.Run.versionFlag], + output: .string(limit: 4_096), + error: .discarded + ) + return result?.standardOutput + } + + internal func runSwift( + for invocation: SwiftInvocation + ) async throws -> SwiftRunOutcome { + try await Subprocess.Configuration.runSwift(for: invocation) + } + } + + extension Skit.Run { + /// The active spawn backend, or nil when this platform has no Subprocess + /// backend (Windows, embedded) and `run` therefore can't spawn `swift`. + internal static let swiftBackend: (any SwiftBackend)? = SubprocessBackend() + } + +#else + + extension Skit.Run { + /// No Subprocess backend on this platform — `run` reports `unsupportedPlatform`. + internal static let swiftBackend: (any SwiftBackend)? = nil + } + +#endif diff --git a/Sources/skit/TerminationStatus+ExitCode.swift b/Sources/skit/TerminationStatus+ExitCode.swift new file mode 100644 index 00000000..aa0900e8 --- /dev/null +++ b/Sources/skit/TerminationStatus+ExitCode.swift @@ -0,0 +1,49 @@ +// +// TerminationStatus+ExitCode.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Subprocess + + extension TerminationStatus { + /// Collapses the termination status into a single exit code, using the + /// shell convention (128 + signal number) for signalled deaths. + internal var exitCode: Int32 { + switch self { + case .exited(let code): + return Int32(truncatingIfNeeded: code) + #if !os(Windows) + case .signaled(let signal): + return 128 + Int32(truncatingIfNeeded: signal) + #endif + } + } + } + +#endif diff --git a/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift b/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift new file mode 100644 index 00000000..0542f925 --- /dev/null +++ b/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift @@ -0,0 +1,101 @@ +// +// SkitSubprocessTimeoutTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Foundation + import Subprocess + import Testing + + // Regression test for swift-subprocess #256: + // + // + // The skit timeout watchdog (Sources/skit/Runner.swift) races a Subprocess + // run() call against `Task.sleep(timeout)`. On timeout it calls + // `group.cancelAll()`, which Subprocess turns into a teardown sequence on + // the spawned `swift`. The reported bug: when the spawned child has a + // grandchild that inherited the pipe FDs, the parent's stream read can hang + // waiting for an EOF that won't arrive until the grandchild exits. + // + // skit's real-world trigger is `swift Input.swift`, which fork-exec's the + // Swift frontend + linker as grandchildren. We approximate that here with a + // shell pipeline that forks a background `sleep` holding stderr open. + @Suite("Subprocess timeout-cancel") + internal struct SkitSubprocessTimeoutTests { + private enum Outcome: Equatable, Sendable { + case completed(TerminationStatus) + case timedOut + } + + @Test( + "cancel-on-timeout completes within a bounded wall-time when grandchildren hold pipe fds" + ) + internal func cancelWithBackgroundedGrandchild() async throws { + let start = ContinuousClock.now + + let outcome: Outcome = try await withThrowingTaskGroup(of: Outcome.self) { group in + group.addTask { + // Outer shell spawns a backgrounded grandchild that holds stderr + // open for 30s, then itself sleeps 30s. Both must be forcibly + // killed by the teardown to free our stream reads. + let record = try await run( + .name("sh"), + arguments: ["-c", "(sleep 30 >/dev/null 2>&1 &) ; sleep 30"], + output: .discarded, + error: .discarded + ) + return .completed(record.terminationStatus) + } + group.addTask { + try await Task.sleep(for: .seconds(1)) + return .timedOut + } + let first = try #require(await group.next()) + group.cancelAll() + return first + } + + let elapsed = ContinuousClock.now - start + + #expect( + outcome == .timedOut, + "timeout task should win the race against a 30s sleep" + ) + // Generous bound: cancellation + Subprocess teardown should complete + // well under 15s. If swift-subprocess #256 triggers, this test fails + // (the run task hangs ≥30s waiting for the grandchild to release the + // stderr pipe). + #expect( + elapsed < .seconds(15), + "timeout-cancel took \(elapsed); possible swift-subprocess #256 regression" + ) + } + } + +#endif diff --git a/Tests/SyntaxKitTests/Platform.swift b/Tests/SyntaxKitTests/Platform.swift new file mode 100644 index 00000000..022a8cf1 --- /dev/null +++ b/Tests/SyntaxKitTests/Platform.swift @@ -0,0 +1,43 @@ +// +// Platform.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Compile-time platform facts surfaced as runtime booleans for use with +/// Swift Testing's `.enabled(if:)` / `.disabled(if:)` traits. +internal enum Platform { + /// True on WebAssembly/WASI. The render pipeline relies on host filesystem + /// behavior (atomic temp writes) and ultimately spawning `swift`, neither of + /// which is available there, so render tests are disabled on this platform. + internal static let isWASI: Bool = { + #if os(WASI) + return true + #else + return false + #endif + }() +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift b/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift new file mode 100644 index 00000000..5e20f436 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift @@ -0,0 +1,78 @@ +// +// ContentHasherTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct ContentHasherTests { + /// The FNV-1a offset basis, as the 16-char hex an empty hasher must finalize to. + private static let emptyDigest = "cbf29ce484222325" + + private func digest(of data: Data) -> String { + var hasher = ContentHasher() + hasher.update(data: data) + return hasher.finalize() + } + + @Test("Empty input finalizes to the FNV-1a offset basis") + internal func emptyInputDigest() { + let hasher = ContentHasher() + #expect(hasher.finalize() == Self.emptyDigest) + #expect(digest(of: Data()) == Self.emptyDigest) + } + + @Test("Same bytes produce the same digest across fresh hashers") + internal func determinism() { + let data = Data("the quick brown fox".utf8) + let first = digest(of: data) + let second = digest(of: data) + #expect(first == second) + } + + @Test("Different inputs produce different digests") + internal func distinctInputs() { + #expect(digest(of: Data("alpha".utf8)) != digest(of: Data("beta".utf8))) + } + + @Test("Byte order is significant") + internal func orderSensitivity() { + #expect(digest(of: Data([0x01, 0x02])) != digest(of: Data([0x02, 0x01]))) + } + + @Test("Chunked updates accumulate identically to a single update") + internal func chunkedAccumulation() { + var chunked = ContentHasher() + chunked.update(data: Data("AB".utf8)) + chunked.update(data: Data("C".utf8)) + + #expect(chunked.finalize() == digest(of: Data("ABC".utf8))) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift b/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift new file mode 100644 index 00000000..15143638 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift @@ -0,0 +1,142 @@ +// +// FileManagerFileSystemTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct FileManagerFileSystemTests { + private let fileManager = FileManager.default + + /// Creates a fresh empty temp directory, runs `body` against it, and removes + /// it afterward. + private func withTempDir(_ body: (URL) throws -> Void) throws { + let dir = fileManager.temporaryDirectory + .appendingPathComponent("fs-\(UUID().uuidString)") + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: dir) } + try body(dir) + } + + // MARK: - pathKind + + @Test("pathKind classifies directory, file, and missing paths") + internal func pathKindClassifies() throws { + try withTempDir { dir in + #expect(fileManager.pathKind(atPath: dir.path) == .directory) + + let file = dir.appendingPathComponent("a.txt") + try Data("x".utf8).write(to: file) + #expect(fileManager.pathKind(atPath: file.path) == .file) + + #expect(fileManager.pathKind(atPath: dir.appendingPathComponent("nope").path) == .missing) + } + } + + // MARK: - fingerprint + + @Test("fingerprint reports byte size for an existing file") + internal func fingerprintReportsSize() throws { + try withTempDir { dir in + let file = dir.appendingPathComponent("a.bin") + try Data("hello".utf8).write(to: file) + + let fingerprint = try #require(fileManager.fingerprint(atPath: file.path)) + #expect(fingerprint.size == 5) + } + } + + @Test("fingerprint is nil for a missing file") + internal func fingerprintMissing() throws { + try withTempDir { dir in + #expect(fileManager.fingerprint(atPath: dir.appendingPathComponent("nope").path) == nil) + } + } + + // MARK: - regularFiles + + @Test("regularFiles walks recursively, skips hidden + directories, sorted by path") + internal func regularFilesWalk() throws { + try withTempDir { dir in + let sub = dir.appendingPathComponent("sub") + try fileManager.createDirectory(at: sub, withIntermediateDirectories: true) + try Data("1".utf8).write(to: dir.appendingPathComponent("b.swift")) + try Data("2".utf8).write(to: dir.appendingPathComponent("a.swift")) + try Data("3".utf8).write(to: sub.appendingPathComponent("c.swift")) + try Data("4".utf8).write(to: dir.appendingPathComponent(".hidden")) + + let files = try fileManager.regularFiles(under: dir) + let names = files.map(\.lastPathComponent) + + // The hidden file and the `sub` directory itself are excluded; nested + // files are included; the result is sorted by full path. + #expect(names == ["a.swift", "b.swift", "c.swift"]) + #expect(files == files.sorted { $0.path < $1.path }) + } + } + + @Test("regularFiles yields nothing for a missing directory") + internal func regularFilesMissingDir() throws { + try withTempDir { dir in + // `enumerator(at:)` returns an empty (non-nil) enumerator for a missing + // directory rather than nil, so the walk yields an empty list rather than + // throwing — matching the directory-mode behavior of treating an empty + // input set as a no-op. + let missing = dir.appendingPathComponent("does-not-exist") + let files = try fileManager.regularFiles(under: missing) + #expect(files.isEmpty) + } + } + + // MARK: - writeData + + @Test("writeData creates intermediate directories") + internal func writeDataCreatesIntermediates() throws { + try withTempDir { dir in + let destination = dir.appendingPathComponent("a/b/c/out.txt") + try fileManager.writeData(Data("payload".utf8), to: destination) + + #expect(fileManager.pathKind(atPath: destination.path) == .file) + #expect(try Data(contentsOf: destination) == Data("payload".utf8)) + } + } + + // MARK: - URL.rerooted + + @Test("rerooted preserves the relative subpath under a new base") + internal func rerootedPreservesSubpath() { + let input = URL(fileURLWithPath: "/in/sub/a.swift") + let rerooted = input.rerooted( + from: URL(fileURLWithPath: "/in"), + onto: URL(fileURLWithPath: "/out") + ) + #expect(rerooted.path == "/out/sub/a.swift") + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift b/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift new file mode 100644 index 00000000..d24da0e8 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift @@ -0,0 +1,129 @@ +// +// OutputCacheTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct OutputCacheTests { + /// A value-type `EnvironmentProvider` whose environment is fixed, so + /// cache-key derivation can be tested without depending on the real process + /// environment. A value type rather than a `ProcessInfo` subclass because + /// `swift-corelibs-foundation` (Linux/Windows) declares `ProcessInfo` as + /// `final` with `environment` on an extension — subclass-and-override + /// doesn't compile there. + private struct FakeEnvironment: EnvironmentProvider { + let environment: [String: String] + let processIdentifier: Int32 = 1 + } + + /// A stub `ContentHashing` that ignores its input and returns a fixed digest, + /// used to prove `OutputCache` derives keys through the injected factory. + private struct StubHasher: ContentHashing { + private static let fixedDigest = "stub-digest" + mutating func update(data: Data) {} + func finalize() -> String { Self.fixedDigest } + } + + private static let libPath = "/nonexistent/lib" + + private func cache( + swiftVersion: String? = "swift 6.1", + environment: [String: String] = [:] + ) -> OutputCache { + OutputCache( + swiftVersion: swiftVersion, + environmentProvider: FakeEnvironment(environment: environment) + ) + } + + private func key(_ cache: OutputCache, source: String = "Struct(\"Foo\") {}") -> String { + cache.key(forInput: source, libPath: Self.libPath) + } + + @Test("key() derives the digest through the injected hasher factory") + internal func usesInjectedHasher() { + let cache = OutputCache( + swiftVersion: "swift 6.1", + environmentProvider: FakeEnvironment(environment: [:]), + makeHasher: { StubHasher() } + ) + #expect(cache.key(forInput: "anything", libPath: Self.libPath) == "stub-digest") + } + + @Test("The same inputs always derive the same key") + internal func stableKey() { + let cache = cache() + let firstKey = key(cache) + let secondKey = key(cache) + #expect(firstKey == secondKey) + } + + @Test("Different source bytes derive different keys") + internal func sourceSensitivity() { + let cache = cache() + #expect(key(cache, source: "Struct(\"A\") {}") != key(cache, source: "Struct(\"B\") {}")) + } + + @Test("A different swift version derives a different key") + internal func swiftVersionSensitivity() { + #expect(key(cache(swiftVersion: "swift 6.1")) != key(cache(swiftVersion: "swift 6.2"))) + } + + @Test("SKIT_/SYNTAXKIT_ env vars are mixed into the key") + internal func relevantEnvVarsChangeKey() { + let base = key(cache(environment: [:])) + #expect(key(cache(environment: ["SKIT_FOO": "1"])) != base) + #expect(key(cache(environment: ["SYNTAXKIT_BAR": "1"])) != base) + } + + @Test("Unrelated env vars are ignored by the key") + internal func irrelevantEnvVarsIgnored() { + let base = key(cache(environment: [:])) + #expect(key(cache(environment: ["PATH": "/usr/bin", "HOME": "/root"])) == base) + } + + @Test("store then lookup round-trips the rendered payload") + internal func storeLookupRoundTrip() throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("outputcache-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + // XDG_CACHE_HOME redirects the cache root; it isn't a SKIT_/SYNTAXKIT_ var, + // so it doesn't affect the key itself. + let cache = cache(environment: ["XDG_CACHE_HOME": cacheRoot.path]) + let key = key(cache) + let payload = Data("rendered output".utf8) + + #expect(cache.lookup(key: key) == nil) + try cache.store(key: key, data: payload) + #expect(cache.lookup(key: key) == payload) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift new file mode 100644 index 00000000..c29920f6 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift @@ -0,0 +1,124 @@ +// +// RunnerBatchRenderTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// Exercises the filesystem-free batch entry point `Runner.render(sources:)`: +/// it reads nothing and writes nothing, returning a `FileOutcome` per input +/// carrying the rendered `stdout` (or a `RunError`). Disabled on WASI because +/// the internal temp-wrapper write the render still performs is unreliable +/// there (see `RunnerRenderFailureTests`). +@Suite internal struct RunnerBatchRenderTests { + /// Marker a source carries to make the stub backend fail that input. + private static let failMarker = "// FAIL" + + /// A runner whose stub backend reads the wrapped program and reports a + /// non-zero exit for any input whose source contains `failMarker`, and exit 0 + /// (with a fixed rendered payload) otherwise — so a single call can mix + /// successes and failures without spawning `swift`. + private func runner(toolchain: ToolchainVerification = .verified) -> Runner { + Runner( + libPath: "/does/not/matter", + cache: nil, + timeoutSeconds: 0, + toolchainVerification: toolchain + ) { invocation in + let wrapped = (try? String(contentsOfFile: invocation.wrappedPath, encoding: .utf8)) ?? "" + if wrapped.contains(Self.failMarker) { + return .completed(ProcessResult(exitCode: 1, stdout: Data(), stderr: "boom\n")) + } + return .completed(ProcessResult(exitCode: 0, stdout: Data("RENDERED".utf8), stderr: "")) + } + } + + private func input(_ name: String, _ source: String) -> RenderInput { + RenderInput(url: URL(fileURLWithPath: "/in/\(name)"), source: source) + } + + @Test("An empty input set yields no outcomes without touching the backend") + internal func emptyYieldsEmpty() async { + let outcomes = await runner().render(sources: []) + #expect(outcomes.isEmpty) + } + + @Test( + "Every successful input returns its rendered stdout with a nil result", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func successesCarryStdout() async { + let outcomes = await runner().render(sources: [ + input("a.swift", "let a = 1\n"), + input("b.swift", "let b = 2\n"), + ]) + + #expect(outcomes.count == 2) + #expect(outcomes.failureCount == 0) + for outcome in outcomes { + #expect(outcome.result == nil) + #expect(outcome.stdout == Data("RENDERED".utf8)) + } + // The input URLs are preserved so the caller can reroot each output. + #expect(Set(outcomes.map(\.input.lastPathComponent)) == ["a.swift", "b.swift"]) + } + + @Test( + "A failing input is isolated: peers still render, failureCount reflects it", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func failureIsIsolated() async { + let outcomes = await runner().render(sources: [ + input("ok.swift", "let ok = 1\n"), + input("bad.swift", "\(Self.failMarker)\nlet bad = 2\n"), + ]) + + let byName = Dictionary( + uniqueKeysWithValues: outcomes.map { ($0.input.lastPathComponent, $0) } + ) + + #expect(outcomes.count == 2) + #expect(outcomes.failureCount == 1) + + let okOutcome = try? #require(byName["ok.swift"]) + #expect(okOutcome?.result == nil) + #expect(okOutcome?.stdout == Data("RENDERED".utf8)) + + let badOutcome = try? #require(byName["bad.swift"]) + #expect(badOutcome?.stdout.isEmpty == true) + guard case .renderFailed(let exitCode, let stderr, let toolchain)? = badOutcome?.result else { + Issue.record("expected .renderFailed for the marked input") + return + } + #expect(exitCode == 1) + #expect(stderr == "boom\n") + #expect(toolchain == .verified) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift new file mode 100644 index 00000000..e6aac682 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift @@ -0,0 +1,100 @@ +// +// RunnerRenderFailureTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct RunnerRenderFailureTests { + /// Runs `render(source:originalPath:)` against in-memory source with a stub + /// backend that always reports a non-zero exit, so the render fails + /// deterministically without spawning `swift`. Returns the thrown `RunError`, + /// or nil if the call unexpectedly succeeded. + private func renderFailure( + toolchain: ToolchainVerification + ) async -> RunError? { + let runner = Runner( + libPath: "/does/not/matter", + cache: nil, + timeoutSeconds: 0, + toolchainVerification: toolchain + ) { _ in + .completed(ProcessResult(exitCode: 1, stdout: Data(), stderr: "boom\n")) + } + + do { + // Omit `originalPath` — exercises the optional anonymous-snippet path. + _ = try await runner.render(source: "let x = 1\n") + return nil + } catch { + return error + } + } + + @Test( + "renderFailed carries the session's toolchain verification and diagnostics", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func carriesUnverifiedToolchain() async { + guard + case .renderFailed(let exitCode, let stderr, let toolchain)? = + await renderFailure(toolchain: .unverified) + else { + Issue.record("expected .renderFailed") + return + } + #expect(exitCode == 1) + #expect(stderr == "boom\n") + #expect(toolchain == .unverified) + } + + @Test( + "renderFailed reflects a verified toolchain unchanged", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func reflectsVerifiedToolchain() async { + guard + case .renderFailed(_, _, let toolchain)? = + await renderFailure(toolchain: .verified) + else { + Issue.record("expected .renderFailed") + return + } + #expect(toolchain == .verified) + } + + @Test("A directly-constructed Runner defaults to notChecked") + internal func defaultsToNotChecked() { + let runner = Runner(libPath: "/x", cache: nil, timeoutSeconds: 0) { _ in + .completed(ProcessResult(exitCode: 0, stdout: Data(), stderr: "")) + } + #expect(runner.toolchainVerification == .notChecked) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift b/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift new file mode 100644 index 00000000..d3adc966 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift @@ -0,0 +1,111 @@ +// +// ToolchainCheckResultTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct ToolchainCheckResultTests { + private static let stampFilename = "swift-version.txt" + + /// Creates a fresh temp lib dir, optionally seeded with a `swift-version.txt` + /// stamp, runs `body` against its path, and removes the dir afterward. + private func withLibDir( + stamp: String?, + _ body: (String) throws -> Void + ) throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("toolchain-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + if let stamp { + try Data(stamp.utf8).write(to: dir.appendingPathComponent(Self.stampFilename)) + } + try body(dir.path) + } + + @Test("Identical bundle/local versions match") + internal func match() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1") + guard case .match = result else { + Issue.record("expected .match, got \(result)") + return + } + } + } + + @Test("Trailing whitespace and newlines are normalized before comparison") + internal func normalizesWhitespace() throws { + try withLibDir(stamp: "swift 6.1\n ") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1\n") + guard case .match = result else { + Issue.record("expected .match, got \(result)") + return + } + } + } + + @Test("Differing versions report a mismatch carrying both strings") + internal func mismatch() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.2") + guard case .mismatch(let bundle, let local) = result else { + Issue.record("expected .mismatch, got \(result)") + return + } + #expect(bundle == "swift 6.1") + #expect(local == "swift 6.2") + } + } + + @Test("A missing stamp file yields stampMissing") + internal func missingStamp() throws { + try withLibDir(stamp: nil) { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1") + guard case .stampMissing = result else { + Issue.record("expected .stampMissing, got \(result)") + return + } + } + } + + @Test("A nil local version yields stampMissing") + internal func nilLocalVersion() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: nil) + guard case .stampMissing = result else { + Issue.record("expected .stampMissing, got \(result)") + return + } + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift b/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift new file mode 100644 index 00000000..eed99de7 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift @@ -0,0 +1,105 @@ +// +// WrappedSourceTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct WrappedSourceTests { + private static let path = "/tmp/input.swift" + + private func rendered(_ source: String) -> String { + WrappedSource(source: source, originalPath: Self.path).rendered + } + + /// The wrapper always opens with the SyntaxKit import and emits the + /// `Group { … }` + `print(...)` scaffold, regardless of input. + private func expectScaffold(_ rendered: String) { + #expect(rendered.contains("import SyntaxKit")) + #expect(rendered.contains("let __skit_root = Group {")) + #expect(rendered.contains("print(__skit_root.generateCode())")) + #expect(rendered.contains("#sourceLocation(file: \"\(Self.path)\"")) + } + + @Test("No-import source is left in the body, starting at line 1") + internal func noImports() { + let out = rendered("Struct(\"Foo\") {}") + expectScaffold(out) + #expect(out.contains("Struct(\"Foo\") {}")) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Leading imports are hoisted above the wrapper body") + internal func importsOnly() { + let out = rendered("import Foundation\nimport SwiftSyntax\n") + expectScaffold(out) + #expect(out.contains("import Foundation")) + #expect(out.contains("import SwiftSyntax")) + // Imports-only input has no body, so the fence starts at line 1. + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Imports are hoisted and the body fence reports the body's line") + internal func mixedImportsAndBody() { + let out = rendered("import Foundation\nlet x = 1\n") + expectScaffold(out) + #expect(out.contains("import Foundation")) + #expect(out.contains("let x = 1")) + // The body slice starts at the newline that is leading trivia of `let`, so + // the fence reports line 1 and the retained leading newline keeps `let x = 1` + // aligned to its original line 2. + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + + // The hoisted import must sit ahead of the `Group {` scaffold, not inside it. + let importIndex = try? #require(out.range(of: "import Foundation")) + let groupIndex = try? #require(out.range(of: "let __skit_root = Group {")) + if let importIndex, let groupIndex { + #expect(importIndex.lowerBound < groupIndex.lowerBound) + } + } + + @Test("A comment before a non-import statement stays in the body") + internal func leadingCommentBeforeBody() { + let out = rendered("// note\nlet x = 1\n") + expectScaffold(out) + // The comment is leading trivia of the first (non-import) statement, so the + // body slice — and thus the rendered output — retains it. + #expect(out.contains("// note")) + #expect(out.contains("let x = 1")) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Empty source still renders the scaffold") + internal func emptySource() { + let out = rendered("") + expectScaffold(out) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } +}