diff --git a/.github/workflows/SyntaxKit.yml b/.github/workflows/SyntaxKit.yml index 1363b825..fb5cd8c8 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"} @@ -229,12 +226,15 @@ jobs: runs-on: macos-26 xcode: "/Applications/Xcode_26.5.app" + # iOS / watchOS / tvOS / visionOS osVersion pinned to 26.4 until the + # macos-26 runner image ships Xcode 26.5 simulators — see #160. + # iOS Build Matrix - type: ios runs-on: macos-26 xcode: "/Applications/Xcode_26.5.app" deviceName: "iPhone 17 Pro" - osVersion: "26.5" + osVersion: "26.4.1" download-platform: true # watchOS Build Matrix @@ -258,7 +258,7 @@ jobs: runs-on: macos-26 xcode: "/Applications/Xcode_26.5.app" deviceName: "Apple Vision Pro" - osVersion: "26.5" + osVersion: "26.4.1" download-platform: true steps: 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/.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..008b2ddc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,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..35baa073 --- /dev/null +++ b/Docs/skit.md @@ -0,0 +1,206 @@ +# `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, two layers of 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 +import SyntaxKitHelpers // optional — only if a Helpers/ dir is present + +equatableModel("Person", fields: [ + ("name", "String"), + ("age", "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 +import SyntaxKitHelpers // hoisted from the input + +let __skit_root = Group { +#sourceLocation(file: "/path/to/Models.swift", line: 3) +equatableModel("Person", fields: [ + ("name", "String"), + ("age", "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. The user's `import`s — typically `import SyntaxKitHelpers` plus anything their Helpers/ module references — 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. + +## Caches + +Two layers, both keyed by content hash. They live under `~/Library/Caches/com.brightdigit.SyntaxKit/` on macOS, `$XDG_CACHE_HOME/syntaxkit` (or `~/.cache/syntaxkit`) on Linux. + +| Layer | Path | What it skips on hit | +| ------- | ----------------------------- | --------------------------------------------- | +| Helpers | `helpers//` | the `swiftc` compile of `Helpers/*.swift` | +| Output | `outputs//output.swift` | the `swift` spawn for an input | + +**Helpers cache.** Each project's `Helpers/` directory gets compiled into `libSyntaxKitHelpers.{dylib,so}` once and reused. The cache key is a hash of the helper sources, plus the bundled `libSyntaxKit` stamp, plus `swift --version`. Touching a helper file invalidates one shard; updating the toolchain invalidates everything. Hit on a warm cache: skip the ~1–2s `swiftc` compile entirely. + +**Output cache.** The fully-rendered output of an input gets cached by a hash of (input bytes, helpers shard, libSyntaxKit stamp, swift version, sorted `SKIT_*`/`SYNTAXKIT_*` env vars). On hit, `skit` doesn't spawn `swift` at all — 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 output cache. There's no flag to skip the helpers cache — invalidate it by touching a helper or bumping the toolchain. + +## 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-release.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. Helpers compile is cached. 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`. + +## Helpers + +Shared codegen utilities live in a `Helpers/` directory. `skit` walks up from the input, finds the nearest `Helpers/`, and compiles its sources into a Swift dylib that the wrapped input can `import SyntaxKitHelpers`. + +``` +project/ +├── Helpers/ +│ └── Models.swift # public func equatableModel(_:fields:) -> any CodeBlock +└── inputs/ + ├── Person.swift # imports SyntaxKitHelpers, calls equatableModel(...) + └── Pet.swift # same +``` + +Files prefixed with `_` are skipped — a convention for private helpers within the helpers module. The module name is hard-coded to `SyntaxKitHelpers`. + +The helpers cache is per-content, so editing a helper triggers a fresh compile but reading the same helper across many inputs hits the cache. + +## 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 helper function that uses **plain Swift `if`/`else`** (not a `Group { if … }` body) to return one of two `CodeBlock`s: + +```swift +// In Helpers/Models.swift +public 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 + } +} + +// In Input.swift +Struct("Config") { + Variable(.let, name: "name", type: "String") + optionalDebugField(buildIsDebug) +} +``` + +The helper 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-release.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, helpers layout). +- [`Scripts/build-skit-release.sh`](../Scripts/build-skit-release.sh) — release-bundle builder. +- [`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..ae344130 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// 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 PackageDescription @@ -98,7 +98,9 @@ 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") ], targets: [ .target( @@ -141,12 +143,29 @@ let package = Package( ), .executableTarget( name: "skit", - dependencies: ["SyntaxParser"], + dependencies: [ + "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]) + ) + ], 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-debug.sh b/Scripts/build-skit-debug.sh new file mode 100755 index 00000000..6e931561 --- /dev/null +++ b/Scripts/build-skit-debug.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Build a self-contained skit DEBUG bundle for fast local iteration. +# +# Identical layout to Scripts/build-skit-release.sh but skips release-mode +# optimization (5-15 minute SwiftSyntax compile → ~10 seconds). Use this when +# you want to exercise the end-to-end DSL→Swift transformation locally; use +# the release script when staging an actual release bundle. +# +# Output: .build/skit-debug/{skit, lib/} + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS-only. Linux uses a parallel flow." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/.build/skit-debug" +PACKAGE_FILE="$REPO_ROOT/Package.swift" +PACKAGE_BACKUP="$(mktemp)" + +cleanup() { + if [[ -s "$PACKAGE_BACKUP" ]]; then + cp "$PACKAGE_BACKUP" "$PACKAGE_FILE" + fi + rm -f "$PACKAGE_BACKUP" +} +trap cleanup EXIT + +cp "$PACKAGE_FILE" "$PACKAGE_BACKUP" + +echo "==> Flipping SyntaxKit library to type: .dynamic (temporary)" +python3 - "$PACKAGE_FILE" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]) +src = p.read_text() +new_block = ' .library(\n name: "SyntaxKit",\n type: .dynamic,\n targets: ["SyntaxKit"]\n ),' +if new_block in src: + print("Package.swift already has type: .dynamic — leaving as-is.") + sys.exit(0) +old_block = ' .library(\n name: "SyntaxKit",\n targets: ["SyntaxKit"]\n ),' +if old_block not in src: + sys.exit("Package.swift: expected SyntaxKit library product block not found") +p.write_text(src.replace(old_block, new_block, 1)) +PY + +cd "$REPO_ROOT" + +echo "==> swift build --product skit" +swift build --product skit + +echo "==> swift build --product SyntaxKit" +swift build --product SyntaxKit + +BUILD_DIR="$(ls -d .build/*-apple-macosx*/debug 2>/dev/null | head -1)" +if [[ -z "$BUILD_DIR" ]]; then + echo "Could not locate debug build dir under .build//debug" >&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/" +cp -r "$BUILD_DIR/Modules/." "$OUTPUT_DIR/lib/" +cp -r "$REPO_ROOT/.build/checkouts/swift-syntax/Sources/_SwiftSyntaxCShims/include" \ + "$OUTPUT_DIR/lib/_SwiftSyntaxCShims-include" + +install_name_tool -id "@rpath/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/libSyntaxKit.dylib" 2>/dev/null || true + +swift --version > "$OUTPUT_DIR/lib/swift-version.txt" + +echo +echo "==> Debug bundle ready at $OUTPUT_DIR" +echo "==> Try it:" +echo " $OUTPUT_DIR/skit Examples/Completed/card_game/dsl.swift --no-cache" diff --git a/Scripts/build-skit-release.sh b/Scripts/build-skit-release.sh new file mode 100755 index 00000000..78eefe7e --- /dev/null +++ b/Scripts/build-skit-release.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Build a self-contained skit release bundle. +# +# Output: .build/skit-release/ +# skit ← the CLI binary +# lib/ +# libSyntaxKit.dylib ← release + strip -x +# *.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 .build/skit-release/ +# directory anywhere, and `./skit-release/skit ` Just Works — no +# flags, no env vars, no SyntaxKit checkout required. + +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 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/.build/skit-release" +PACKAGE_FILE="$REPO_ROOT/Package.swift" +PACKAGE_BACKUP="$(mktemp)" + +cleanup() { + if [[ -s "$PACKAGE_BACKUP" ]]; then + cp "$PACKAGE_BACKUP" "$PACKAGE_FILE" + fi + rm -f "$PACKAGE_BACKUP" +} +trap cleanup EXIT + +cp "$PACKAGE_FILE" "$PACKAGE_BACKUP" + +echo "==> Flipping SyntaxKit library to type: .dynamic (temporary)" +python3 - "$PACKAGE_FILE" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]) +src = p.read_text() +old = ' .library(\n name: "SyntaxKit",\n targets: ["SyntaxKit"]\n ),' +new = ' .library(\n name: "SyntaxKit",\n type: .dynamic,\n targets: ["SyntaxKit"]\n ),' +if old not in src: + sys.exit("Package.swift: expected SyntaxKit library product block not found") +p.write_text(src.replace(old, new, 1)) +PY + +cd "$REPO_ROOT" + +echo "==> swift build -c release --product skit" +swift build -c release --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 -c release --product SyntaxKit" +swift build -c release --product SyntaxKit + +BUILD_DIR="$(ls -d .build/*-apple-macosx*/release 2>/dev/null | head -1)" +if [[ -z "$BUILD_DIR" ]]; then + echo "Could not locate release build dir under .build//release" >&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/" +strip -x "$OUTPUT_DIR/lib/libSyntaxKit.dylib" +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 it's 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 "==> Release 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/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/skit/ContentHasher.swift b/Sources/skit/ContentHasher.swift new file mode 100644 index 00000000..39227b5d --- /dev/null +++ b/Sources/skit/ContentHasher.swift @@ -0,0 +1,63 @@ +// +// 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. +// + +#if canImport(Subprocess) + + 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. + internal struct ContentHasher { + private static let offsetBasis: UInt64 = 0xcbf2_9ce4_8422_2325 + private static let prime: UInt64 = 0x0000_0100_0000_01b3 + + private var state: UInt64 = ContentHasher.offsetBasis + + internal mutating func update(data: Data) { + for byte in data { + state ^= UInt64(byte) + state &*= ContentHasher.prime + } + } + + /// Returns the hash as a 16-char lowercase-hex string suitable for use as + /// a directory name. + internal func finalize() -> String { + String(format: "%016x", state) + } + } + +#endif diff --git a/Sources/skit/Helpers.swift b/Sources/skit/Helpers.swift new file mode 100644 index 00000000..12430a45 --- /dev/null +++ b/Sources/skit/Helpers.swift @@ -0,0 +1,298 @@ +// +// Helpers.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 + + /// Hardcoded module name for the user's `Helpers/` compilation output. Inputs + /// reach the compiled helpers via `import SyntaxKitHelpers`. + internal let helpersModuleName = "SyntaxKitHelpers" + + /// Platform-specific shared-library filename for a Swift library product. + internal func dylibFilename(forLibrary name: String) -> String { + #if os(Linux) + return "lib\(name).so" + #else + return "lib\(name).dylib" + #endif + } + + /// Bumped when the cache layout changes in a way that requires invalidation. + private let helpersCacheSchemaVersion = "v1" + + /// A compiled `Helpers/` directory ready to splice into the input spawn. + internal struct CompiledHelpers: Sendable { + /// Directory containing `libSyntaxKitHelpers.dylib` + `.swiftmodule` files. + let outputDir: URL + /// Whether the build was reused from cache (false = freshly compiled). + let cacheHit: Bool + } + + // MARK: - Discovery + + /// Walks up from `inputURL` looking for a `Helpers/` directory. Returns the + /// first one found, or nil if no ancestor contains one. + /// + /// When `inputURL` is a file, the search starts from its parent. When it's a + /// directory, the search starts from the directory itself. + internal func discoverHelpersDir(near inputURL: URL) -> URL? { + let fm = FileManager.default + var isDirectory: ObjCBool = false + let exists = fm.fileExists(atPath: inputURL.path, isDirectory: &isDirectory) + var dir = (exists && isDirectory.boolValue) ? inputURL : inputURL.deletingLastPathComponent() + dir = dir.standardizedFileURL + + while true { + let candidate = dir.appendingPathComponent("Helpers") + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + return candidate.standardizedFileURL + } + let parent = dir.deletingLastPathComponent().standardizedFileURL + if parent.path == dir.path { return nil } + dir = parent + } + } + + /// Globs `**/*.swift` under `helpersDir`, skipping files prefixed with `_`. + internal func collectHelperSources(in helpersDir: URL) throws -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: helpersDir, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { + throw CLIError(message: "could not enumerate \(helpersDir.path)") + } + + var result: [URL] = [] + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { continue } + guard url.pathExtension == "swift" else { continue } + guard !url.lastPathComponent.hasPrefix("_") else { continue } + result.append(url.standardizedFileURL) + } + return result.sorted { $0.path < $1.path } + } + + // MARK: - Build pipeline + + /// Compiles helper sources into a per-key cache directory and returns the + /// directory plus a cache-hit flag. Returns nil when the helpers dir is empty. + /// + /// Concurrent invocations are tolerated via the staging-dir + atomic-rename + /// pattern: if two processes race to compile the same key, the loser's + /// rename fails and we keep the winner's artefact. + internal func buildHelpers( + helpersDir: URL, + libPath: String + ) async throws -> CompiledHelpers? { + // Collect helper sources. An empty Helpers/ dir is "no helpers" rather + // than an error — the caller will fall back to no-helpers mode. + let sources = try collectHelperSources(in: helpersDir) + if sources.isEmpty { return nil } + + // Compute the content-keyed cache path. The dylib's presence under that + // path is what makes a build "cached". + let key = try await helpersCacheKey(sources: sources, libPath: libPath) + let cacheRoot = try syntaxKitCacheRoot() + .appendingPathComponent("helpers") + .appendingPathComponent(key) + let dylibPath = + cacheRoot + .appendingPathComponent(dylibFilename(forLibrary: helpersModuleName)).path + + // Cache hit: artefact already present, skip the whole compile. + let fm = FileManager.default + if fm.fileExists(atPath: dylibPath) { + return CompiledHelpers(outputDir: cacheRoot, cacheHit: true) + } + + // Ensure the parent of the cache key dir exists. We don't create the + // key dir itself — the atomic move below installs it. + try fm.createDirectory( + at: cacheRoot.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Compile into a per-pid + uuid staging dir, then atomically rename into + // place. This is what lets concurrent skit invocations co-exist safely. + let staging = cacheRoot.deletingLastPathComponent() + .appendingPathComponent( + "tmp.\(ProcessInfo.processInfo.processIdentifier).\(UUID().uuidString)") + try fm.createDirectory(at: staging, withIntermediateDirectories: true) + + // Run swiftc into the staging dir. Clean up on failure so we don't leak + // half-baked artefacts in the cache root. + do { + try await compileHelpers(sources: sources, into: staging, libPath: libPath) + } catch { + try? fm.removeItem(at: staging) + throw error + } + + // Atomic rename into the cache path. If a peer beat us to it (rename failed + // because the destination now exists), keep theirs and drop ours. + do { + try fm.moveItem(at: staging, to: cacheRoot) + } catch { + try? fm.removeItem(at: staging) + if !fm.fileExists(atPath: dylibPath) { + throw error + } + } + + return CompiledHelpers(outputDir: cacheRoot, cacheHit: false) + } + + /// Invokes `swiftc` to build `sources` into a Swift module + dylib under + /// `outDir`. The dylib is named `lib.{dylib,so}` and the + /// module file is `.swiftmodule`. Output (stdout) + /// is discarded; stderr is captured for the error path. + private func compileHelpers(sources: [URL], into outDir: URL, libPath: String) async throws { + let cShimsInclude = "\(libPath)/_SwiftSyntaxCShims-include" + let dylib = outDir.appendingPathComponent(dylibFilename(forLibrary: helpersModuleName)).path + let modulePath = outDir.appendingPathComponent("\(helpersModuleName).swiftmodule").path + + // Base swiftc arguments: emit a library + module file linking against + // libSyntaxKit, with rpath set so the dylib can find libSyntaxKit at + // load time. + var args: [String] = [ + "-module-name", helpersModuleName, + "-emit-module", + "-emit-module-path", modulePath, + "-parse-as-library", + "-emit-library", + "-o", dylib, + "-suppress-warnings", + "-I", libPath, + "-L", libPath, + "-lSyntaxKit", + "-Xcc", "-I", "-Xcc", cShimsInclude, + "-Xlinker", "-rpath", "-Xlinker", libPath, + ] + #if !os(Linux) + // @rpath install_name is macOS-only; on Linux SONAME isn't needed because + // we use rpath-based loading and the dylib lives in a cache path that's + // known at link time. + args.append(contentsOf: [ + "-Xlinker", "-install_name", + "-Xlinker", "@rpath/\(dylibFilename(forLibrary: helpersModuleName))", + ]) + #endif + // Append source files last so the leading flags apply to all of them. + args.append(contentsOf: sources.map(\.path)) + + // Spawn swiftc. Stderr is captured (1 MiB cap) so a compile failure can + // surface the diagnostic verbatim in a CLIError. + let result = try await run( + .name("swiftc"), + arguments: Arguments(args), + output: .discarded, + error: .string(limit: 1 * 1_024 * 1_024) + ) + + guard result.terminationStatus.isSuccess else { + let stderr = result.standardError ?? "" + throw CLIError( + message: """ + skit: failed to compile Helpers/ (\(result.terminationStatus)) + \(stderr) + """) + } + } + + // MARK: - Cache key + + /// Content-addressed cache key mixing schema version, each helper source's + /// filename + bytes, `swift --version`, and the libSyntaxKit stamp. + private func helpersCacheKey(sources: [URL], libPath: String) async throws -> String { + var hasher = ContentHasher() + hasher.update(data: Data(helpersCacheSchemaVersion.utf8)) + + // Filename matters as well as bytes — two same-content files with + // different names produce different symbols. + for source in sources { + let data = try Data(contentsOf: source) + hasher.update(data: Data(source.lastPathComponent.utf8)) + hasher.update(data: data) + } + + // Toolchain + dylib stamp invalidate on cross-version or in-place rebuild. + if let swiftVersion = await captureSwiftVersion() { + hasher.update(data: Data(swiftVersion.utf8)) + } + if let stamp = libStamp(libPath: libPath) { + hasher.update(data: Data(stamp.utf8)) + } + + return hasher.finalize() + } + + /// Verbatim `swift --version` output, or nil on spawn failure. Capped at 4 KiB. + internal func captureSwiftVersion() async -> String? { + let result = try? await run( + .name("swift"), + arguments: ["--version"], + output: .string(limit: 4_096), + error: .discarded + ) + return result?.standardOutput + } + + /// `/` fingerprint of the bundled libSyntaxKit dylib, or nil + /// if unreadable. Catches in-place rebuilds without a version bump. + internal func libStamp(libPath: String) -> String? { + let dylib = "\(libPath)/\(dylibFilename(forLibrary: "SyntaxKit"))" + guard let attrs = try? FileManager.default.attributesOfItem(atPath: dylib) else { return nil } + let size = (attrs[.size] as? NSNumber)?.intValue ?? 0 + let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + return "\(size)/\(Int(mtime))" + } + + /// Root for all skit caches. Honours `XDG_CACHE_HOME`, else macOS + /// `~/Library/Caches/...` or Linux `~/.cache/syntaxkit`. + internal func syntaxKitCacheRoot() throws -> URL { + if let xdg = ProcessInfo.processInfo.environment["XDG_CACHE_HOME"], !xdg.isEmpty { + return URL(fileURLWithPath: xdg).appendingPathComponent("syntaxkit") + } + let home = NSHomeDirectory() + #if os(macOS) + return URL(fileURLWithPath: home) + .appendingPathComponent("Library/Caches/com.brightdigit.SyntaxKit") + #else + return URL(fileURLWithPath: home).appendingPathComponent(".cache/syntaxkit") + #endif + } + +#endif diff --git a/Sources/skit/OutputCache.swift b/Sources/skit/OutputCache.swift new file mode 100644 index 00000000..e68dd841 --- /dev/null +++ b/Sources/skit/OutputCache.swift @@ -0,0 +1,132 @@ +// +// 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. +// + +#if canImport(Subprocess) + + import Foundation + + /// Bumped when the output cache layout changes in a way that requires invalidation. + private let outputCacheSchemaVersion = "v1" + + /// 64-bit content hash over (cache schema, input source bytes, helpers key, + /// 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. + internal func outputCacheKey( + inputSource: String, + helpers: CompiledHelpers?, + libPath: String + ) async -> String { + var hasher = ContentHasher() + // Schema version: bump to invalidate every existing cache entry at once. + hasher.update(data: Data(outputCacheSchemaVersion.utf8)) + // Input source bytes: the primary driver of the key. + hasher.update(data: Data(inputSource.utf8)) + + // Helpers fingerprint. The helpers cache dir name *is* the helpers cache + // key (per Helpers.swift), so re-mixing it here cheaply propagates any + // helpers change into this key. + if let helpers { + hasher.update(data: Data(helpers.outputDir.lastPathComponent.utf8)) + } else { + hasher.update(data: Data("no-helpers".utf8)) + } + + // Toolchain version. Different `swift` builds emit different bytes for + // the same DSL input. + if let version = await captureSwiftVersion() { + hasher.update(data: Data(version.utf8)) + } + // libSyntaxKit stamp. A rebuilt dylib can change the rendered output + // even without a Swift-version bump. + if let stamp = 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 = ProcessInfo.processInfo.environment + .filter { $0.key.hasPrefix("SKIT_") || $0.key.hasPrefix("SYNTAXKIT_") } + .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. + internal func lookupCachedOutput(key: String) -> Data? { + guard let dir = try? outputCacheDir(for: key) else { return nil } + return try? Data(contentsOf: dir.appendingPathComponent("output.swift")) + } + + /// Atomically stores `data` under `key`. Concurrent writers race via a + /// `tmp../` staging dir + rename; the loser drops their copy. + internal func storeCachedOutput(key: String, data: Data) throws { + let cacheRoot = try outputCacheDir(for: key) + let final = cacheRoot.appendingPathComponent("output.swift") + let fm = FileManager.default + + // Ensure the parent of the cache key dir exists. The key dir itself is + // installed by the atomic rename below. + try fm.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( + "tmp.\(ProcessInfo.processInfo.processIdentifier).\(UUID().uuidString)" + ) + try fm.createDirectory(at: staging, withIntermediateDirectories: true) + try data.write(to: staging.appendingPathComponent("output.swift")) + + // 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 fm.moveItem(at: staging, to: cacheRoot) + } catch { + try? fm.removeItem(at: staging) + if !fm.fileExists(atPath: final.path) { + throw error + } + } + } + + private func outputCacheDir(for key: String) throws -> URL { + try syntaxKitCacheRoot() + .appendingPathComponent("outputs") + .appendingPathComponent(key) + } + +#endif diff --git a/Sources/skit/README.md b/Sources/skit/README.md new file mode 100644 index 00000000..630250a5 --- /dev/null +++ b/Sources/skit/README.md @@ -0,0 +1,99 @@ +# 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-release.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 +import SyntaxKitHelpers // optional — only if a Helpers/ dir is present + +equatableModel("Person", fields: [ + ("name", "String"), + ("age", "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. + +## Helpers + +Shared codegen utilities live in a `Helpers/` directory anywhere up-tree from the input. `skit` walks up from the input file (or directory) looking for one. Sources are pre-compiled into `libSyntaxKitHelpers.{dylib,so}` once and cached by content hash: + +``` +project/ +├── Helpers/ +│ └── Models.swift # public func equatableModel(_:fields:) -> any CodeBlock +└── inputs/ + ├── Person.swift # imports SyntaxKitHelpers, calls equatableModel(...) + └── Pet.swift # same +``` + +Files prefixed with `_` are skipped (convention for private helpers within helpers). The helper module name is hard-coded to `SyntaxKitHelpers`. + +Force-disable: `--no-helpers`. Override location: `--helpers `. + +## Caches + +Two layers, both keyed on content + toolchain + dylib stamp + `SKIT_*`/`SYNTAXKIT_*` env vars. Live under `~/Library/Caches/com.brightdigit.SyntaxKit/` on macOS, `$XDG_CACHE_HOME/syntaxkit` (or `~/.cache/syntaxkit`) on Linux. + +| Layer | Path | What it skips on hit | +| ------- | ----------------------------- | --------------------------------------------- | +| Helpers | `helpers//` | the `swiftc` compile of `Helpers/*.swift` | +| Output | `outputs//output.swift` | the `swift` spawn for an input | + +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/`. | +| `--helpers ` | walk-up | Explicit `Helpers/` directory. | +| `--no-helpers` | (off) | Skip helpers discovery entirely. | +| `--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-release.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` and `Helpers.swift` work around it with `terminationHandler` + `DispatchSemaphore`. + +## Deeper dive + +For the architecture, design decisions, and trade-offs see [`Docs/skit.md`](../../Docs/skit.md). diff --git a/Sources/skit/Runner.swift b/Sources/skit/Runner.swift new file mode 100644 index 00000000..4a8513d0 --- /dev/null +++ b/Sources/skit/Runner.swift @@ -0,0 +1,720 @@ +// +// 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. +// + +#if canImport(Subprocess) + + import Foundation + import Subprocess + import SwiftParser + import SwiftSyntax + + // Run lifecycle (per `skit run` invocation): + // 1. resolveLibPath — find lib/ (explicit flag → env → adjacent → brew) + // 2. toolchainCheck — compare bundle stamp to `swift --version` + // 3. resolveHelpers — discover + compile Helpers/ (memoised on disk) + // 4. runSingleFile / runDirectory — dispatch to single- or batch-input mode + // 5. processFile (per input) — load → cache lookup → wrap → spawn → cache store + // 6. wrap — hoist imports, wrap body in Group { … }, #sourceLocation + // 7. runSwift — spawn `swift` with timeout watchdog + // See Docs/skit.md for design rationale and trade-offs. + + // MARK: - Helpers resolution + + /// How `skit run` should treat a `Helpers/` directory for this invocation. + internal enum HelpersOptions { + /// Walk up from the input looking for `Helpers/`. The default. + case auto + /// Skip helpers discovery entirely (`--no-helpers`). The wrapped input + /// won't be able to `import SyntaxKitHelpers`. + case disabled + /// Use the directory at the given path (`--helpers `). Validated as + /// an existing directory in `resolveHelpers`. + case explicit(String) + } + + /// Resolves a `Helpers/` directory and compiles it (or returns the cached + /// build). Returns nil when helpers are disabled, when no `Helpers/` was + /// found in auto mode, or when the directory exists but contains no `.swift` + /// sources. On success, writes a one-line "skit: helpers cached/compiled at + /// " note to stderr so users can see whether the cache hit. + internal func resolveHelpers( + nearInputPath path: String, + libPath: String, + options: HelpersOptions + ) async throws -> CompiledHelpers? { + // Pick the helpers dir according to the mode: walk up the tree, accept an + // explicit override (after validating it's a directory), or bail out. + let helpersDir: URL? + switch options { + case .disabled: + return nil + case .auto: + helpersDir = discoverHelpersDir(near: URL(fileURLWithPath: path).standardizedFileURL) + case .explicit(let dir): + let url = URL(fileURLWithPath: dir).standardizedFileURL + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), + isDir.boolValue + else { + throw CLIError(message: "--helpers path is not a directory: \(dir)") + } + helpersDir = url + } + guard let helpersDir else { return nil } + + // Compile (or reuse the cached build). An empty Helpers/ dir is treated + // as "no helpers" rather than an error. + guard let compiled = try await buildHelpers(helpersDir: helpersDir, libPath: libPath) else { + return nil + } + let suffix = compiled.cacheHit ? "cached" : "compiled" + FileHandle.standardError.write( + Data( + "skit: helpers \(suffix) at \(helpersDir.path)\n".utf8 + )) + return compiled + } + + // MARK: - Resource location + + /// Resolves the directory containing `libSyntaxKit.dylib` + module files, + /// in priority order: explicit flag → env var → adjacent-to-binary + /// (`/lib/`) → Homebrew layout (`/../lib/skit/`). + internal func resolveLibPath(override: String?) throws -> String { + if let override { + guard isLibDir(override) else { + throw CLIError(message: "--lib path does not look like a SyntaxKit lib dir: \(override)") + } + return override + } + + if let env = ProcessInfo.processInfo.environment["SKIT_LIB_DIR"], !env.isEmpty { + guard isLibDir(env) else { + throw CLIError(message: "SKIT_LIB_DIR is set but path is not a lib dir: \(env)") + } + return env + } + + if let execURL = Bundle.main.executableURL?.resolvingSymlinksInPath() { + let execDir = execURL.deletingLastPathComponent() + + let adjacent = execDir.appendingPathComponent("lib").path + if isLibDir(adjacent) { return adjacent } + + let brewLayout = execDir.deletingLastPathComponent() + .appendingPathComponent("lib/skit").path + if isLibDir(brewLayout) { return brewLayout } + } + + throw CLIError( + message: """ + Could not locate SyntaxKit lib directory. Looked for: + 1. --lib (not provided) + 2. $SKIT_LIB_DIR (not set) + 3. /lib/ (not found) + 4. /../lib/skit/ (not found) + Run Scripts/build-skit-release.sh to produce a self-contained + release bundle under .build/skit-release/. + """) + } + + private func isLibDir(_ path: String) -> Bool { + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { return false } + return fm.fileExists(atPath: "\(path)/\(dylibFilename(forLibrary: "SyntaxKit"))") + } + + // MARK: - Toolchain check + + /// Filename for the bundle's recorded build-toolchain version. + internal let toolchainStampFilename = "swift-version.txt" + + internal enum ToolchainCheckResult { + /// Bundle stamp matches the local `swift --version` exactly. + case match + /// `/swift-version.txt` is missing (older bundle that predates + /// the stamp). skit prints a one-line note and proceeds. + case stampMissing + case mismatch(bundle: String, local: String) + } + + /// Compares `/swift-version.txt` to `captureSwiftVersion()`. + /// 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. + internal func toolchainCheck(libPath: String) async -> ToolchainCheckResult { + let stampURL = URL(fileURLWithPath: libPath).appendingPathComponent(toolchainStampFilename) + guard let stampData = try? Data(contentsOf: stampURL), + let stampRaw = String(data: stampData, encoding: .utf8) + else { + FileHandle.standardError.write( + Data("skit: bundle has no toolchain stamp; skipping check\n".utf8) + ) + return .stampMissing + } + guard let localRaw = await captureSwiftVersion() else { + FileHandle.standardError.write( + Data("skit: could not capture local `swift --version`; skipping toolchain check\n".utf8) + ) + return .stampMissing + } + let bundle = stampRaw.trimmingCharacters(in: .whitespacesAndNewlines) + let local = localRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return bundle == local ? .match : .mismatch(bundle: bundle, local: local) + } + + internal func toolchainMismatchMessage(bundle: String, local: String) -> String { + """ + skit: 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: + Scripts/build-skit-release.sh + Or pass --no-toolchain-check to try anyway. + + """ + } + + // MARK: - Single-file mode + + /// Runs `processFile` on a single input and writes its rendered Swift to + /// `outputPath` (or stdout when nil). Any stderr from the spawned `swift` + /// is surfaced verbatim. On a non-zero subprocess exit, calls `exit()` + /// directly — the caller in `Skit.Run.run()` won't see a thrown error in + /// that path. + internal func runSingleFile( + inputPath: String, + outputPath: String?, + libPath: String, + helpers: CompiledHelpers?, + useCache: Bool, + timeoutSeconds: Int + ) async throws { + // Render the input. `processFile` may hit the output cache and skip the + // spawn entirely; either way the result has the same shape. + let result = try await processFile( + inputPath: inputPath, + libPath: libPath, + helpers: helpers, + useCache: useCache, + timeoutSeconds: timeoutSeconds + ) + // Surface diagnostics from the spawned `swift` before deciding success. + if !result.stderr.isEmpty { + FileHandle.standardError.write(Data(result.stderr.utf8)) + } + // Non-zero subprocess exit propagates as a process exit. We don't write + // partial output in that case. + guard result.exitCode == 0 else { + exit(result.exitCode) + } + // Deliver the rendered output to file or stdout. + if let outputPath { + try result.stdout.write(to: URL(fileURLWithPath: outputPath)) + } else { + FileHandle.standardOutput.write(result.stdout) + } + } + + // MARK: - Folder mode + + /// Walks `inputDir` for `.swift` inputs, processes them concurrently (up to + /// the active core count), and mirrors the rendered output into `outputDir`. + /// A failure on one input does not abort the batch — successful peers are + /// still written. Returns 0 if every input succeeded, 1 otherwise. + internal func runDirectory( + inputDir: String, + outputDir: String, + libPath: String, + helpers: CompiledHelpers?, + useCache: Bool, + timeoutSeconds: Int + ) async -> Int32 { + let inputURL = URL(fileURLWithPath: inputDir).standardizedFileURL + let outputURL = URL(fileURLWithPath: outputDir).standardizedFileURL + + // Phase 1: enumerate inputs. Top-level `Helpers/` is excluded so its + // sources aren't processed as DSL inputs. + let inputs: [URL] + do { + inputs = try collectInputs(at: inputURL, excluding: helpersExcludePath(inputDir: inputURL)) + } catch { + FileHandle.standardError.write(Data("skit: failed to walk \(inputDir): \(error)\n".utf8)) + return 1 + } + + if inputs.isEmpty { + FileHandle.standardError.write(Data("skit: no .swift inputs under \(inputDir)\n".utf8)) + return 0 + } + + // Phase 2: bounded-concurrency processing. Cap is the active core count + // so a 200-file batch doesn't fork 200 simultaneous `swift` processes. + let maxConcurrent = max(1, ProcessInfo.processInfo.activeProcessorCount) + + var outcomes: [FileOutcome] = [] + var iterator = inputs.makeIterator() + + await withTaskGroup(of: FileOutcome.self) { group in + // Seed the group up to the concurrency cap… + for _ in 0.. + } + + /// `processFile` adapter that catches errors into the `FileOutcome` result + /// so a single failure doesn't tear down the surrounding `TaskGroup`. + private func runOne( + _ input: URL, + libPath: String, + helpers: CompiledHelpers?, + useCache: Bool, + timeoutSeconds: Int + ) async -> FileOutcome { + do { + let result = try await processFile( + inputPath: input.path, + libPath: libPath, + helpers: helpers, + useCache: useCache, + timeoutSeconds: timeoutSeconds + ) + return FileOutcome(input: input, result: .success(result)) + } catch { + return FileOutcome(input: input, result: .failure(error)) + } + } + + /// Returns the path of a `Helpers/` directory living directly under `inputDir`, + /// so the folder-mode enumerator can skip its descendants. Helpers that live + /// outside the input tree don't need to be excluded (they aren't enumerated). + private func helpersExcludePath(inputDir: URL) -> String? { + let candidate = inputDir.appendingPathComponent("Helpers").standardizedFileURL + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDir), + isDir.boolValue + else { + return nil + } + return candidate.path + } + + /// Returns every `.swift` file under `inputDir` (recursive), sorted, with + /// hidden files, files prefixed by `_`, and the `excludedDir` subtree + /// removed. Sorted output keeps batch behaviour deterministic across runs. + private func collectInputs(at inputDir: URL, excluding excludedDir: String?) throws -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: inputDir, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + throw CLIError(message: "could not enumerate \(inputDir.path)") + } + + var result: [URL] = [] + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + // Directories aren't outputs; if this is the excluded `Helpers/` dir, + // prune the whole subtree. + if values.isDirectory == true { + if let excludedDir, url.standardizedFileURL.path == excludedDir { + enumerator.skipDescendants() + } + continue + } + // Filter for `.swift` regular files, skipping the `_`-prefixed + // convention for "not an input" sources. + guard values.isRegularFile == true else { continue } + guard url.pathExtension == "swift" else { continue } + guard !url.lastPathComponent.hasPrefix("_") else { continue } + result.append(url.standardizedFileURL) + } + return result.sorted { $0.path < $1.path } + } + + // MARK: - Per-file work + + /// 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). + private struct ProcessResult: Sendable { + let exitCode: Int32 + let stdout: Data + let stderr: String + } + + /// The per-input render pipeline: load source → consult the output cache → + /// (on miss) wrap → spawn `swift` → rewrite diagnostics → store the result + /// in the cache. The temp wrapper file is created in a per-run tmp dir and + /// torn down by `defer` whether the spawn succeeded or not. + private func processFile( + inputPath: String, + libPath: String, + helpers: CompiledHelpers?, + useCache: Bool, + timeoutSeconds: Int + ) async throws -> ProcessResult { + // Load the input source. Anything past this point keys off these bytes. + let inputURL = URL(fileURLWithPath: inputPath).standardizedFileURL + let absoluteInputPath = inputURL.path + let source = try String(contentsOf: inputURL, encoding: .utf8) + + // Compute the output cache key (skipped under `--no-cache`). Mixes input + // bytes, toolchain version, helpers fingerprint, libSyntaxKit stamp, and + // sorted SKIT_*/SYNTAXKIT_* env vars — see `outputCacheKey`. + let cacheKey: String? = + useCache + ? await outputCacheKey(inputSource: source, helpers: helpers, libPath: libPath) + : nil + // Cache hit: skip the wrap+spawn entirely and return the stored output. + if let cacheKey, let cached = lookupCachedOutput(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 `wrap` for the exact template. + let wrapped = wrap(source: source, originalPath: absoluteInputPath) + + // 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.default.temporaryDirectory + .appendingPathComponent("skit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let wrappedURL = tmpDir.appendingPathComponent("Input.wrapped.swift") + 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, + libPath: libPath, + helpers: helpers, + timeoutSeconds: timeoutSeconds + ) + // #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 cacheKey, raw.exitCode == 0 { + try? storeCachedOutput(key: cacheKey, data: raw.stdout) + } + + return ProcessResult(exitCode: raw.exitCode, stdout: raw.stdout, stderr: stderr) + } + + /// Throwable error wrapper for skit's user-facing diagnostics. The message + /// is printed verbatim — keep it actionable (path, hint, next step). + internal struct CLIError: Error, CustomStringConvertible { + let message: String + var description: String { message } + } + + // MARK: - Wrapping + + /// Splits the input into hoisted `import` declarations and a verbatim body, + /// returning 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. + internal func wrap(source: String, originalPath: String) -> String { + // 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. 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 top-level + // `// comment` is left alone). + 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 + } + + // 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. + let body: String + let firstBodyLine: Int + if let firstBodyByte { + let start = source.utf8.index(source.utf8.startIndex, offsetBy: firstBodyByte.utf8Offset) + body = String(source[start...]) + firstBodyLine = locConverter.location(for: firstBodyByte).line + } else { + body = "" + firstBodyLine = 1 + } + + // 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 = hoisted.isEmpty ? "" : hoisted.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()) + """ + } + + // MARK: - Spawning swift + + /// Exit code returned when the spawned `swift` is killed by skit's timeout + /// watchdog. Matches POSIX `timeout(1)`. + private let timeoutExitCode: Int32 = 124 + + /// Bounded output capacity for the spawned `swift` (16 MiB). Generated DSL + /// output above this size is exotic; if we ever hit it we'll see a clear + /// SubprocessError rather than a silent truncation. + private let stdoutLimitBytes: Int = 16 * 1_024 * 1_024 + private let stderrLimitBytes: Int = 1 * 1_024 * 1_024 + + /// 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. + private enum SwiftRunOutcome: Sendable { + case completed(exitCode: Int32, stdout: Data, stderr: String) + case timedOut + } + + /// Spawns `swift` (script-mode interpreter) on the wrapped input file, + /// optionally splicing in flags to import a precompiled helpers module. + /// 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, + libPath: String, + helpers: CompiledHelpers?, + timeoutSeconds: Int + ) async throws -> ProcessResult { + let cShimsInclude = "\(libPath)/_SwiftSyntaxCShims-include" + + // Build the base argument list: link against libSyntaxKit, include the + // CShims headers, set rpath so the dylib loads at runtime. + var arguments: [String] = [ + "-suppress-warnings", + "-I", libPath, + "-L", libPath, + "-lSyntaxKit", + "-Xcc", "-I", "-Xcc", cShimsInclude, + "-Xlinker", "-rpath", "-Xlinker", libPath, + ] + + // Splice in helpers-module flags only when a compiled helpers dylib is + // available. Skipping these makes `import SyntaxKitHelpers` fail in the + // wrapped input, which is fine when no Helpers/ dir was discovered. + if let helpers { + let helpersPath = helpers.outputDir.path + arguments.append(contentsOf: [ + "-I", helpersPath, + "-L", helpersPath, + "-l\(helpersModuleName)", + "-Xlinker", "-rpath", "-Xlinker", helpersPath, + ]) + } + + arguments.append(wrappedPath) + let argumentsCopy = arguments + + // The actual subprocess call, wrapped in a closure so the task-group race + // below can hold a single Sendable reference to it. + let invocation: @Sendable () async throws -> SwiftRunOutcome = { + let record = try await run( + .name("swift"), + arguments: Arguments(argumentsCopy), + output: .string(limit: stdoutLimitBytes), + error: .string(limit: stderrLimitBytes) + ) + return .completed( + exitCode: exitCode(from: record.terminationStatus), + stdout: Data((record.standardOutput ?? "").utf8), + stderr: record.standardError ?? "" + ) + } + + // 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 invocation() + } else { + outcome = try await withThrowingTaskGroup(of: SwiftRunOutcome.self) { group in + group.addTask { try await invocation() } + group.addTask { + try await Task.sleep(for: .seconds(timeoutSeconds)) + return .timedOut + } + let first = try await group.next()! + group.cancelAll() + return first + } + } + + // Normalize both outcomes into a single ProcessResult shape. + switch outcome { + case .completed(let exitCode, let stdout, let stderr): + return ProcessResult(exitCode: exitCode, stdout: stdout, stderr: stderr) + case .timedOut: + return ProcessResult( + exitCode: timeoutExitCode, + stdout: Data(), + stderr: "skit: timed out after \(timeoutSeconds)s\n" + ) + } + } + + /// Collapses Subprocess's `TerminationStatus` into a single Int32 exit code, + /// using the shell convention (128 + signal number) for signalled deaths. + private func exitCode(from status: TerminationStatus) -> Int32 { + switch status { + case .exited(let code): + return Int32(truncatingIfNeeded: code) + #if !os(Windows) + case .signaled(let signal): + // Match shell convention: 128 + signal number. + return 128 + Int32(truncatingIfNeeded: signal) + #endif + } + } + +#endif diff --git a/Sources/skit/Skit+Parse.swift b/Sources/skit/Skit+Parse.swift new file mode 100644 index 00000000..47ad22de --- /dev/null +++ b/Sources/skit/Skit+Parse.swift @@ -0,0 +1,51 @@ +// +// 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 { + internal static let configuration = CommandConfiguration( + commandName: "parse", + 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..baf8d7b1 --- /dev/null +++ b/Sources/skit/Skit+Run.swift @@ -0,0 +1,198 @@ +// +// 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 + +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 free functions in `Runner.swift`. + internal struct Run: AsyncParsableCommand { + internal static let configuration = CommandConfiguration( + commandName: "run", + 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("output")], + help: "Output file (single-file mode) or directory (folder mode)." + ) + internal var output: String? + + @Option( + name: .customLong("lib"), + help: "Directory containing libSyntaxKit.dylib + module files." + ) + internal var libPath: String? + + @Option( + name: .customLong("helpers"), + help: "Override Helpers/ directory location." + ) + internal var helpersDir: String? + + @Flag( + name: .customLong("no-helpers"), + help: "Skip helpers discovery entirely." + ) + internal var noHelpers: Bool = false + + @Flag( + name: .customLong("no-cache"), + help: "Skip the rendered-output cache (always run swift)." + ) + internal var noCache: Bool = false + + @Option( + name: .customLong("timeout"), + help: "Per-input timeout for the spawned `swift` in seconds (0 disables)." + ) + internal var timeoutSeconds: Int = 60 + + @Flag( + name: .customLong("no-toolchain-check"), + help: "Skip the bundle/local Swift-toolchain comparison." + ) + internal var noToolchainCheck: Bool = false + + internal func validate() throws { + guard timeoutSeconds >= 0 else { + throw ValidationError( + "--timeout expects a non-negative integer (seconds), got: \(timeoutSeconds)" + ) + } + } + + internal func run() async throws { + #if canImport(Subprocess) + // 1. Resolve the libSyntaxKit bundle dir. Failure here is fatal — we + // can't spawn `swift` without knowing where the dylib + swiftmodules + // live. The error message lists the four lookup paths in priority order. + let libPath: String + do { + libPath = try resolveLibPath(override: self.libPath) + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + throw ExitCode(2) + } + + // 2. Compare the bundle's recorded `swift --version` against the local + // one. swiftmodules aren't reliably forward-compatible across compiler + // versions, so a mismatch produces a clear error rather than letting + // the spawned `swift` emit a cryptic module-version diagnostic. + if !noToolchainCheck { + switch await toolchainCheck(libPath: libPath) { + case .match, .stampMissing: + break + case .mismatch(let bundle, let local): + FileHandle.standardError.write( + Data(toolchainMismatchMessage(bundle: bundle, local: local).utf8)) + throw ExitCode(2) + } + } + + // 3. Decide which helpers-resolution mode this invocation is in. + // The actual discovery / compilation happens later in `resolveHelpers`. + let helpersOptions: HelpersOptions + if noHelpers { + helpersOptions = .disabled + } else if let dir = helpersDir { + helpersOptions = .explicit(dir) + } else { + helpersOptions = .auto + } + + // 4. Stat the input to pick single-file vs. directory mode. Directory + // mode requires an explicit `-o` output dir; single-file mode falls + // back to stdout. + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: input, isDirectory: &isDirectory) else { + throw ValidationError("input does not exist: \(input)") + } + + if isDirectory.boolValue { + guard let output else { + throw ValidationError("directory inputs require -o ") + } + // 5a. Resolve helpers relative to the input root. This is the only + // place we compile `Helpers/`; the result is reused across every + // input file in the directory. + let helpers = try await resolveHelpers( + nearInputPath: input, + libPath: libPath, + options: helpersOptions + ) + // 6a. Hand off to the directory orchestrator and surface its exit + // code via ExitCode (so a partial-failure batch returns 1). + let exitCode = await runDirectory( + inputDir: input, + outputDir: output, + libPath: libPath, + helpers: helpers, + useCache: !noCache, + timeoutSeconds: timeoutSeconds + ) + throw ExitCode(exitCode) + } else { + // 5b. Resolve helpers relative to this single file's parent. + let helpers = try await resolveHelpers( + nearInputPath: input, + libPath: libPath, + options: helpersOptions + ) + // 6b. Hand off to the single-file orchestrator. It calls `exit()` + // directly on non-zero subprocess exit, so a thrown ExitCode here + // would be unreachable in that path. + try await runSingleFile( + inputPath: input, + outputPath: output, + libPath: libPath, + helpers: helpers, + useCache: !noCache, + timeoutSeconds: timeoutSeconds + ) + } + #else + // Subprocess is the only backend skit knows how to use to spawn + // `swift`/`swiftc`. Without it (Windows, embedded), `run` cannot work. + FileHandle.standardError.write( + Data("skit: run is not supported on this platform (no Subprocess backend).\n".utf8) + ) + throw ExitCode(1) + #endif + } + } +} diff --git a/Sources/skit/Skit.swift b/Sources/skit/Skit.swift index 1066b5e2..d5cb9360 100644 --- a/Sources/skit/Skit.swift +++ b/Sources/skit/Skit.swift @@ -27,24 +27,20 @@ // 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) ?? "" - - // Parse the code using SyntaxKit - let treeNodes = SyntaxParser.parse(code: code) - - // Convert to JSON for output - let encoder = JSONEncoder() - let data = try encoder.encode(treeNodes) - let json = String(decoding: data, as: UTF8.self) - - // Output the JSON - print(json) - } +internal struct Skit: AsyncParsableCommand { + internal static let configuration = CommandConfiguration( + commandName: "skit", + abstract: "Render SyntaxKit DSL into Swift source, or parse Swift into JSON.", + subcommands: [Run.self, Parse.self], + defaultSubcommand: Run.self + ) } diff --git a/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift b/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift new file mode 100644 index 00000000..ebd083ed --- /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 { + @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 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" + ) + } + + private enum Outcome: Equatable, Sendable { + case completed(TerminationStatus) + case timedOut + } + } + +#endif