diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 00000000..069f32e9 --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,29 @@ +name: Setup mise tools +description: >- + Restore (or build + save) the mise tool cache and put the binaries on PATH. + Implemented as a composite action so the cache scope is the caller job's + scope — reusable workflows scope caches separately, which silently breaks + hand-off between a setup job and a consumer lint job. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/.github/workflows/SyntaxKit.yml b/.github/workflows/SyntaxKit.yml index ebfd1d44..ca966bde 100644 --- a/.github/workflows/SyntaxKit.yml +++ b/.github/workflows/SyntaxKit.yml @@ -31,21 +31,43 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip') }} outputs: - full-matrix: ${{ steps.set-matrix.outputs.full-matrix }} - ubuntu-os: ${{ steps.set-matrix.outputs.ubuntu-os }} - ubuntu-swift: ${{ steps.set-matrix.outputs.ubuntu-swift }} - ubuntu-type: ${{ steps.set-matrix.outputs.ubuntu-type }} + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.matrix.outputs.ubuntu-type }} steps: - - name: Determine build matrix - id: set-matrix + - id: check + name: Determine matrix scope run: | - if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.event_name }}" == "pull_request" ]]; then - echo "full-matrix=true" >> "$GITHUB_OUTPUT" + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + 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 "full-matrix=false" >> "$GITHUB_OUTPUT" echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" @@ -57,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"} @@ -86,6 +105,9 @@ jobs: run: | apt-get update -q apt-get install -y curl + - name: Install coverage.py (silences codecov-cli probe warning) + if: steps.build.outputs.contains-code-coverage == 'true' + run: pip3 install --quiet --user coverage 2>/dev/null || true - uses: sersoft-gmbh/swift-coverage-action@v5 id: coverage-files if: steps.build.outputs.contains-code-coverage == 'true' @@ -151,7 +173,7 @@ jobs: include: # SPM Build — no platform type; matrix.type evaluates to '' by design - runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" steps: - uses: actions/checkout@v6 @@ -202,38 +224,38 @@ jobs: # macOS Build - type: macos runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" # iOS Build Matrix - type: ios runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" deviceName: "iPhone 17 Pro" - osVersion: "26.4" + osVersion: "26.5" download-platform: true # watchOS Build Matrix - type: watchos runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.4" + osVersion: "26.5" download-platform: true # tvOS Build Matrix - type: tvos runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" deviceName: "Apple TV" - osVersion: "26.4" + osVersion: "26.5" download-platform: true # visionOS Build Matrix - type: visionos runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" + xcode: "/Applications/Xcode_26.5.app" deviceName: "Apple Vision Pro" - osVersion: "26.4" + osVersion: "26.5" download-platform: true steps: @@ -273,7 +295,7 @@ jobs: swift: - version: "6.2" - version: "6.3" - android-api-level: [33, 34, 35, 36] + android-api-level: [33, 34, 35] steps: - uses: actions/checkout@v6 - name: Free disk space @@ -310,11 +332,14 @@ jobs: if: ${{ !cancelled() && !failure() && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} runs-on: ubuntu-latest needs: [build-ubuntu, build-macos, build-windows, build-macos-full, build-android] + # Serialize lint runs across workflows so a cold cache on a mise.toml + # bump only triggers one rebuild, not a race. + concurrency: + group: lint-tools-${{ github.head_ref || github.ref }} + cancel-in-progress: false steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@v4 - with: - cache: true + - uses: ./.github/actions/setup-tools - name: Lint run: ./Scripts/lint.sh diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml new file mode 100644 index 00000000..348f4430 --- /dev/null +++ b/.github/workflows/check-unsafe-flags.yml @@ -0,0 +1,39 @@ +name: Check for unsafeFlags + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + dump-package-check: + name: Dump Swift package (authoritative) and scan JSON + runs-on: ubuntu-latest + container: + image: swift:latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install jq + run: | + apt-get update && apt-get install -y jq + + - name: Dump package JSON and check for unsafeFlags + shell: bash + run: | + set -euo pipefail + # Compute unsafeFlags array directly from the dump (don't store the full dump variable) + unsafe_flags=$(swift package dump-package | jq -c '[.. | objects | .unsafeFlags? // empty]') + # Check array length to decide failure + if [ "$(echo "$unsafe_flags" | jq 'length')" -gt 0 ]; then + echo "ERROR: unsafeFlags found in resolved package JSON:" + echo "$unsafe_flags" | jq '.' || true + echo "--- resolved package dump (first 200 lines) ---" + # Print a sample of the authoritative dump (re-run dump-package for the sample) + swift package dump-package | sed -n '1,200p' || true + exit 1 + else + echo "No unsafeFlags in resolved package JSON." + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5e7f78bf..aa09eaf8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,11 +34,9 @@ on: jobs: analyze: name: Analyze - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners - # Consider using larger runners for possible analysis time improvements. + # CodeQL Swift analysis requires macOS runners — Linux is not supported + # ("Swift analysis is only supported on macOS runner images"). Other languages + # can run on Linux, hence the conditional. runs-on: ${{ (matrix.language == 'swift' && 'macos-26') || 'ubuntu-latest' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: @@ -60,9 +58,11 @@ jobs: uses: actions/checkout@v6 - name: Setup Xcode - run: sudo xcode-select -s /Applications/Xcode_26.4.app/Contents/Developer + if: matrix.language == 'swift' + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - name: Verify Swift Version + if: matrix.language == 'swift' run: | swift --version swift package --version diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml new file mode 100644 index 00000000..4ab0b691 --- /dev/null +++ b/.github/workflows/swift-source-compat.yml @@ -0,0 +1,30 @@ +name: Swift Source Compatibility + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + swift-source-compat-suite: + name: Test Swift ${{ matrix.container }} For Source Compatibility Suite + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + + strategy: + fail-fast: false + matrix: + container: + - swift:6.1 + - swift:6.2 + - swift:6.3 + + container: ${{ matrix.container }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Test Swift 6.x For Source Compatibility + run: swift build --disable-sandbox --verbose --configuration release 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/.mise.toml b/.mise.toml deleted file mode 100644 index a5bd89f0..00000000 --- a/.mise.toml +++ /dev/null @@ -1,6 +0,0 @@ -[tools] -swiftlint = "0.63.2" -periphery = {version = "3.7.2", os = ["macos"]} -# Community asdf plugin — builds swift-format from source (no official binary releases) -# Plugin repo: https://github.com/eelcokoelewijn/asdf-swift-format -"asdf:eelcokoelewijn/asdf-swift-format" = "602.0.0" diff --git a/.swift-format b/.swift-format index a657e6cc..5393ff74 100644 --- a/.swift-format +++ b/.swift-format @@ -33,9 +33,9 @@ "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, - "NeverForceUnwrap" : false, - "NeverUseForceTry" : false, - "NeverUseImplicitlyUnwrappedOptionals" : false, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, "NoAccessLevelOnExtensionDeclaration" : true, "NoAssignmentInExpressions" : true, "NoBlockComments" : true, @@ -56,13 +56,13 @@ "TypeNamesShouldBeCapitalized" : true, "UseEarlyExits" : false, "UseExplicitNilCheckInConditions" : true, - "UseLetInEveryBoundCaseVariable" : false, + "UseLetInEveryBoundCaseVariable" : true, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : true, "UseSynthesizedInitializer" : true, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : true, - "ValidateDocumentationComments" : false + "ValidateDocumentationComments" : true }, "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, diff --git a/.swift-version b/.swift-version deleted file mode 100644 index a435f5a5..00000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -6.1 diff --git a/.swiftlint.yml b/.swiftlint.yml index e468b7c6..43ef0b6a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,6 +11,7 @@ opt_in_rules: - contains_over_range_nil_comparison - convenience_type - discouraged_object_literal + - discouraged_optional_boolean - empty_collection_literal - empty_count - empty_string @@ -52,11 +53,11 @@ opt_in_rules: - nslocalizedstring_require_bundle - number_separator - object_literal + - one_declaration_per_file - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension - - pattern_matching_keywords - prefer_self_type_over_type_of_self - prefer_zero_over_explicit_init - private_action @@ -114,6 +115,10 @@ type_name: excluded: - If - Do + min_length: 3 + max_length: + warning: 50 + error: 60 excluded: - DerivedData - .build @@ -138,3 +143,9 @@ disabled_rules: - opening_brace - optional_data_string_conversion - todo +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/CLAUDE.md b/CLAUDE.md index a09a578d..57cf92e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,16 @@ Sources/SyntaxKit/ 4. Run `./Scripts/lint.sh` to ensure code quality 5. Run `swift test` to verify functionality +### Coding Conventions +- **No global functions or variables.** Every function and `let`/`var` must be + nested inside a type (`struct`/`enum`/`class`) or an `extension`. This includes + declarations that look indented only because they sit inside a `#if … #endif` + conditional-compilation block (e.g. `Sources/skit/Runner.swift`) — those are + still file-scope globals and must be moved into a type. This is not currently + enforced by SwiftLint: a `custom_rules` regex can't reliably distinguish a + global nested in `#if` from a legitimate type member, so it needs an + AST-aware (SwiftSyntax) check to enforce. Until then, follow the rule by hand. + ### Package Dependencies - **SwiftSyntax** (601.0.1+) - Apple's Swift syntax parser - **SwiftOperators** - Operator handling @@ -93,8 +103,8 @@ Sources/SyntaxKit/ 2. **skit Executable** - Command-line tool for parsing Swift code to JSON ### Platform Support -- Swift 6.0+ required -- Xcode 16.0+ for development +- Swift 6.1+ required +- Xcode 16.3+ for development ### Testing - Uses modern Swift Testing framework (`@Test` syntax) diff --git a/Docs/research/tuist-manifest-pipeline.md b/Docs/research/tuist-manifest-pipeline.md new file mode 100644 index 00000000..313464b8 --- /dev/null +++ b/Docs/research/tuist-manifest-pipeline.md @@ -0,0 +1,265 @@ +# How Tuist Evaluates Manifests + +> Phase 1 deliverable for [issue #154](https://github.com/brightdigit/SyntaxKit/issues/154). +> Source: read of `tuist/tuist@main` and `swiftlang/swift-package-manager@main` via GitHub. Every behavioral claim cites a file/line in those repos. + +## TL;DR + +- **Manifest is run, not pre-compiled to an artifact.** Tuist invokes `/usr/bin/xcrun swift ` in Swift script/interpreter mode with `-I/-L/-F` pointing at `ProjectDescription.framework` (`cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:374-528`). There is no separate `.o`/executable for the manifest itself. +- **JSON over stdout, token-delimited.** The manifest's `Project(...)` initializer calls `dumpIfNeeded(self)` (`cli/Sources/ProjectDescription/Dump.swift:3-14`) which `JSONEncoder`s `self` and prints it bracketed by `TUIST_MANIFEST_START` / `TUIST_MANIFEST_END`; the host scans stdout for those tokens and `JSONDecoder`s the slice between them (`ManifestLoader.swift:119-120, 347-365`). +- **Helpers are real dylibs.** Files under `/Tuist/ProjectDescriptionHelpers/` are compiled by a separate `swiftc -emit-module -emit-library -parse-as-library` invocation into `lib.dylib`, cached on disk, and added to the manifest's `swift` invocation via `-I/-L/-F/-l` (`cli/Sources/TuistLoader/ProjectDescriptionHelpers/ProjectDescriptionHelpersBuilder.swift:174-298`). +- **Two-layer cache.** The helpers module is keyed by an md5 of (file SHA-256s + Tuist version + Swift toolchain version + macOS version + macOS SDK version + tuist env vars + DEBUG flag) (`ProjectDescriptionHelpersHasher.swift:34-55`). The decoded manifest JSON itself is cached in `~/.tuist/Cache/Manifests`, keyed by (manifest SHA-256 + helpers hash + plugins hash + env hash + sandbox flag + Tuist version + cache schema version) (`CachedManifestLoader.swift:181-275`). +- **Sandboxed.** On macOS the whole `xcrun swift …` command is wrapped in `sandbox-exec -p ` by default; the profile denies everything and re-allows only read access to the project path, Xcode, the `ProjectDescription` search paths, and `/private/tmp` + `/private/var` for writes (`ManifestLoader.swift:492-574`). + +## 1. Compilation pipeline + +The manifest is **not** compiled by Tuist to a separate artifact — it is run with `swift` in interpreter mode. Argument construction lives in `ManifestLoader.buildArguments(_:at:disableSandbox:)`: + +```swift +// cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:392-401 +var arguments = [ + "/usr/bin/xcrun", + "swift", + "-suppress-warnings", + "-I", searchPaths.includeSearchPath.pathString, + "-L", searchPaths.librarySearchPath.pathString, + "-F", searchPaths.frameworkSearchPath.pathString, + "-l\(frameworkName)", + "-framework", frameworkName, +] +``` + +Then helpers are stitched in (`ManifestLoader.swift:423-428`): + +```swift +let projectDescriptionHelperArguments = projectDescriptionHelperModules.flatMap { [ + "-I", $0.path.parentDirectory.pathString, + "-L", $0.path.parentDirectory.pathString, + "-F", $0.path.parentDirectory.pathString, + "-l\($0.name)", +] } +``` + +The manifest path itself is appended last (`ManifestLoader.swift:478`), and `--tuist-dump` is added by the caller (`ManifestLoader.swift:344`). There is **no** `-target`, `-sdk`, `-module-name`, `-emit-executable`, or `-emit-library` on the manifest run — those are SwiftPM-style flags that Tuist deliberately avoids for the manifest. The implicit target/SDK come from `xcrun swift` itself (i.e. from the active Xcode selected via `xcode-select`). + +**For `Package.swift` (the `.packageSettings` case)**, Tuist additionally points `-I/-L/-F` at `XcodeDefault.xctoolchain/usr/lib/swift/pm/ManifestAPI`, links `-lPackageDescription`, and passes `-package-description-version -D TUIST` and a JSON-encoded `-context ` argument that SwiftPM's `PackageDescription` runtime expects (`ManifestLoader.swift:430-490`). + +**Helpers compilation** uses `swiftc` directly (`ProjectDescriptionHelpersBuilder.swift:264-298`): + +```swift +var command: [String] = [ + "/usr/bin/xcrun", "swiftc", + "-module-name", moduleName, + "-emit-module", + "-emit-module-path", outputDirectory.appending(component: "\(moduleName).swiftmodule").pathString, + "-parse-as-library", + "-emit-library", + "-suppress-warnings", + "-I", projectDescriptionSearchPaths.includeSearchPath.pathString, + "-L", projectDescriptionSearchPaths.librarySearchPath.pathString, + "-F", projectDescriptionSearchPaths.frameworkSearchPath.pathString, + "-working-directory", outputDirectory.pathString, +] +// + helper-module flags + `-framework ProjectDescription` (or `-lProjectDescription` when dylib) + all *.swift sources +``` + +The helper artifact is a dylib named `lib.dylib` (`ProjectDescriptionHelpersBuilder.swift:188-190`). + +## 2. Execution model + +It's model **(b) — `Process`-spawn + parse stdout**. There is no `dlopen`, no exported C symbol, no plugin entry point. `loadDataForManifest` simply runs the constructed `arguments` via `CommandRunner.capture(...)`: + +```swift +// cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:347-365 +let string = try await commandRunner.capture( + arguments: arguments, + environment: Environment.current.manifestLoadingVariables +) + +guard let startTokenRange = string.range(of: ManifestLoader.startManifestToken, options: .literal), + let endTokenRange = string.range(of: ManifestLoader.endManifestToken, options: [.literal, .backwards]) +else { + return string.data(using: .utf8)! +} +// ... slice out the JSON ... +let manifest = string[startTokenRange.upperBound ..< endTokenRange.lowerBound] +return manifest.data(using: .utf8)! +``` + +The host scans the entire captured stdout for `TUIST_MANIFEST_START` and `TUIST_MANIFEST_END` (`ManifestLoader.swift:119-120`). Anything *before* the start token and *after* the end token is treated as user-visible log output and re-emitted via `Logger.current.notice(...)` (`ManifestLoader.swift:358-362`). This means a manifest can `print(…)` debug output *and* still be parsed correctly — a useful side-effect of the token-delimited design. + +The slice between the tokens is utf8-decoded and passed straight to `JSONDecoder().decode(T.self, from: data)` (`ManifestLoader.swift:256-257`). + +## 3. Bridging format + +JSON, generated by `JSONEncoder` on the manifest side and `JSONDecoder` on the host side. + +**Encode site** (manifest process): `cli/Sources/ProjectDescription/Dump.swift:3-14`: + +```swift +func dumpIfNeeded(_ entity: some Encodable) { + guard !ProcessInfo.processInfo.arguments.isEmpty, + ProcessInfo.processInfo.arguments.contains("--tuist-dump") + else { return } + let encoder = JSONEncoder() + let data = try! encoder.encode(entity) + let manifest = String(data: data, encoding: .utf8)! + print("TUIST_MANIFEST_START") + print(manifest) + print("TUIST_MANIFEST_END") +} +``` + +`dumpIfNeeded(self)` is invoked from inside the *initializer* of each top-level manifest type. For example, `Project.init(...)` ends with `dumpIfNeeded(self)` (`cli/Sources/ProjectDescription/Project.swift:109`). This is what makes a bare `let _ = Project(...)` self-publishing — the user never has to call anything; constructing the value at the top level *is* the side-effect. + +**Decode site** (host process): `cli/Sources/TuistLoader/Loaders/ManifestLoader.swift:127, 175, 257`: + +```swift +private let decoder: JSONDecoder +… +return try decoder.decode(T.self, from: data) +``` + +All top-level `ProjectDescription` types (`Project`, `Workspace`, `Config`, `Template`, `Plugin`, `PackageSettings`) are `Codable` — that's what allows the same `Decodable` constraint on `loadManifest(...)` (`ManifestLoader.swift:244`) to handle every manifest kind. + +Note the asymmetry: the manifest side imports `ProjectDescription` and produces `ProjectDescription.Project`; the host side decodes into the **same** `ProjectDescription.Project` Swift type (`ManifestLoading.loadProject` returns `ProjectDescription.Project`), and only later does `ManifestModelConverter` / `Project+ManifestMapper.swift` map that into `XcodeGraph.Project` (the internal model). So `ProjectDescription` plays a dual role: API surface for users + DTO/IR for the bridge. + +## 4. ProjectDescriptionHelpers + +The flow is: locate → hash → compile-or-reuse → splice flags into the manifest `swift` command. + +**Locate** (`HelpersDirectoryLocator.swift:37-44`): + +```swift +public func locate(at: AbsolutePath) async throws -> AbsolutePath? { + guard let rootDirectory = try await rootDirectoryLocator.locate(from: at) else { return nil } + let helpersDirectory = rootDirectory + .appending(component: Constants.tuistDirectoryName) // "Tuist" + .appending(component: Constants.helpersDirectoryName) // "ProjectDescriptionHelpers" + if try await !fileSystem.exists(helpersDirectory) { return nil } + return helpersDirectory +} +``` + +So the discovery rule is literally: walk up from the manifest's directory to the project root, then look for `Tuist/ProjectDescriptionHelpers/`. If absent, helpers are skipped. + +**Build** (`ProjectDescriptionHelpersBuilder.swift:174-255`): each helpers directory is hashed (see section 5), the hash becomes a subdirectory name under the on-disk cache, and the build is gated on whether that directory already exists. If not, files are globbed (`**/*.swift`), `swiftc` runs into a sibling **staging directory** (`.tmp..`) and is then atomically `rename(2)`'d into place (`ProjectDescriptionHelpersBuilder.swift:204-244`) — explicitly designed to be safe under concurrent Tuist processes. + +**Plugin helpers** (`ProjectDescriptionHelpersBuilder.swift:132-144`) get built first because local helpers may import plugin helpers but not vice-versa. The plugin helper modules are then passed in as `customProjectDescriptionHelperModules` to the local helpers compile, so the local helpers can `import `. + +**Splicing into the manifest invocation** (`ManifestLoader.swift:405-428`): for `.project`, `.template`, `.workspace`, `.packageSettings` manifests, the helpers builder is called; for `.config`, `.plugin`, `.package`, helpers are *not* built (so you can't `import ProjectDescriptionHelpers` from `Tuist.swift`/`Plugin.swift` — the loader logs a clear error if you try, see `logUnexpectedImportErrorIfNeeded`, `ManifestLoader.swift:576-592`). + +In-process the builder also memoizes via `builtHelpers: ThreadSafe<[AbsolutePath: Task<…>]>` (`ProjectDescriptionHelpersBuilder.swift:69, 180-252`), so within one Tuist invocation a helpers directory is compiled at most once even if many manifests are loaded. + +## 5. Caching + +There are **two** caches; both contribute to the "manifest didn't change → skip work" property. + +**Helpers cache** (`ProjectDescriptionHelpersHasher.swift:34-55`): + +```swift +let fileHashes = try await fileSystem + .glob(directory: helpersDirectory, include: ["**/*.swift"]) + .collect() + .sorted() + .compactMap { $0.sha256() } + .compactMap { $0.compactMap { byte in String(format: "%02x", byte) }.joined() } +let tuistEnvVariables = Environment.current.manifestLoadingVariables.map { "\($0.key)=\($0.value)" }.sorted() +let swiftlangVersion = try await SwiftVersionProvider.current.swiftlangVersion() +let macosVersion = machineEnvironment.macOSVersion +let macosSDKVersion = try await MacOSSDKVersionProvider.current.macOSSDKVersion() +… +let identifiers = + [macosVersion, macosSDKVersion, swiftlangVersion, tuistVersion] + fileHashes + tuistEnvVariables + ["\(debug)"] +return identifiers.joined(separator: "-").md5 +``` + +So the helpers cache key is sensitive to: per-file SHA-256s (sorted), macOS version, macOS SDK version, Swift compiler/toolchain version, Tuist version, every `TUIST_*` env var, and DEBUG vs release Tuist build. The on-disk location is `cacheDirectoriesProvider.cacheDirectory(for: .projectDescriptionHelpers)` (`ManifestLoader.swift:402-403`), with the hash as the directory name — defaults under `~/.cache/tuist/…` on macOS / Linux per Tuist's CacheDirectoriesProvider conventions. + +**Decoded-manifest cache** (`CachedManifestLoader.swift:181-275`): after the manifest has been compiled-and-run once, the resulting JSON-encoded `ProjectDescription.Project` value is itself cached at `cacheDirectoriesProvider.cacheDirectory(for: .manifests)` (default `~/.tuist/Cache/Manifests` per the class doc, `CachedManifestLoader.swift:15`), keyed by: + +```swift +// CachedManifestLoader.swift:192-198 +return Hashes( + manifestHash: manifestHash, // SHA-256 of Project.swift + helpersHash: helpersHash, // md5 from ProjectDescriptionHelpersHasher + pluginsHash: try await pluginsHashCache.value?.value, + environmentHash: environmentHash, // md5 of TUIST_* env vars + disableSandboxHash: disableSandboxHash +) +``` + +Plus a `cacheVersion` integer (`CachedManifest.currentCacheVersion = 1`, `CachedManifestLoader.swift:314`) and the Tuist version (`CachedManifestLoader.swift:268-273`) — all five must match for a cache hit. On a hit, Tuist skips spawning `swift` entirely and decodes the cached JSON directly. + +## 6. Toolchain resolution + +Two paths. + +**For `swift` / `swiftc`**: Tuist hard-codes `/usr/bin/xcrun` (`ManifestLoader.swift:393`, `ProjectDescriptionHelpersBuilder.swift:267`) and lets xcrun resolve the toolchain. So the active Xcode (set by `xcode-select -s` or by `DEVELOPER_DIR`) determines `swift`/`swiftc`. Tuist does not appear to honor a `TOOLCHAINS=` or a custom `.xctoolchain` indirectly beyond whatever xcrun itself does. + +**For the SDK / Xcode path** (only needed for `.packageSettings`): `ManifestLoader.swift:432-457`: + +```swift +let xcodePath = try await { + if let developerDir = Environment.current.variables["DEVELOPER_DIR"] { + let developerDirPath = try AbsolutePath(validating: developerDir) + let resolvedXcodePath = if developerDirPath.components.suffix(2) == ["Contents", "Developer"] { + developerDirPath.parentDirectory.parentDirectory + } else { + developerDirPath + } + let manifestPath = resolvedXcodePath + .appending(components: "Contents", "Developer", "Toolchains", + "XcodeDefault.xctoolchain", "usr", "lib", "swift", "pm", "ManifestAPI") + if try await fileSystem.exists(manifestPath) { + return resolvedXcodePath + } + } + return try await XcodeController.current.selected().path +}() +``` + +So `DEVELOPER_DIR` is consulted first (with normalization for either form — pointing at `Xcode.app` or at `Xcode.app/Contents/Developer`), falling back to whatever `xcode-select` reports. + +**For `ProjectDescription.framework` / `libProjectDescription.dylib`**: `ResourceLocator.frameworkPath` (`cli/Sources/TuistLoader/Utils/ResourceLocator.swift:52-83`) searches relative to the Tuist binary's `Bundle.bundleURL`, including a Homebrew-style `bin/` ↔ `lib/` adjacency, and honors `TUIST_FRAMEWORK_SEARCH_PATHS` (space-separated). It will accept any of `libProjectDescription.dylib`, `ProjectDescription.framework`, or `PackageFrameworks/ProjectDescription.framework` — and `ProjectDescriptionSearchPaths.pathStyle(for:)` (`cli/Sources/TuistLoader/Utils/ProjectDescriptionPaths.swift:85-93`) decides whether downstream include/library/framework search paths should be derived dylib-style or framework-style. + +## 7. Failure modes + +- **Syntax error / compile failure**: `swift` exits non-zero, `CommandRunner` throws `CommandError.terminated(exitCode, standardError, command)`, and the host wraps it. Two hooks (`logUnexpectedImportErrorIfNeeded`, `logPluginHelperBuildErrorIfNeeded`, `ManifestLoader.swift:576-604`) inspect stderr and surface friendlier errors for common cases (e.g. importing `ProjectDescriptionHelpers` from `Config.swift`/`Plugin.swift`, or a missing plugin helper module). +- **Runtime crash / non-zero exit**: same path — `CommandRunner.capture(...)` throws, and the loader bubbles it up. +- **No start/end tokens in stdout**: the loader does **not** error; it falls back to treating the entire stdout as the JSON payload (`ManifestLoader.swift:352-356`). The JSON decode will then fail and produce a `ManifestLoaderError.manifestLoadingFailed` with the captured payload included for debugging (`ManifestLoader.swift:259-265`). +- **Decode failure**: each `DecodingError` case (`typeMismatch`, `valueNotFound`, `keyNotFound`, `dataCorrupted`) is mapped to a tailored `manifestLoadingFailed(context:)` error message that includes the offending JSON (`ManifestLoader.swift:267-315`). +- **Missing import / unresolved module**: stderr from `swift` carries the message; the two `logXxxErrorIfNeeded` functions detect the most common culprit (default helpers from a forbidden manifest, or a plugin helper that failed its own build) and emit a targeted log line. Beyond that the raw compile error reaches the user. +- **Hang / timeout**: no process-wait timeout in `ManifestLoader` or `CommandRunner` usage. Tuist appears to wait indefinitely for the `swift` subprocess to finish. (Inferred — `grep` for `timeout|terminate|sleep` in the loader files surfaced nothing.) +- **Empty `Package.swift`** is special-cased: `loadPackageSettings` catches `manifestLoadingFailed` with `data.count == 0` and returns a default `PackageSettings()` (`ManifestLoader.swift:217-230`). +- **Sandbox**: by default on macOS, the `swift` call is wrapped in `sandbox-exec -p ` (`ManifestLoader.swift:516-521`). The profile (`ManifestLoader.swift:547-574`) denies by default, then allows `process*`, `file-read-metadata`, RW under `/private/tmp` and `/private/var`, and read-only access to: the project path, the selected Xcode path, `com.apple.dt.Xcode.plist`, the three ProjectDescription search paths, the resolved `DEVELOPER_DIR`, and every helpers-module parent directory. Manifests can opt out via `disableSandbox: true` (config and plugin always run unsandboxed: `ManifestLoader.swift:195, 207, 234`). + +## 8. SwiftPM comparison + +SwiftPM uses model **(b)** as well but goes one extra step: it actually compiles the manifest into a **temporary executable on disk** and then `Process`-spawns *that* (`Sources/PackageLoading/ManifestLoader.swift:779-908` in `swiftlang/swift-package-manager`). In `evaluateManifest(...)`: + +```swift +// swift-package-manager/Sources/PackageLoading/ManifestLoader.swift:779-786 +let compiledManifestFile = tmpDir.appending("\(packageIdentity)-manifest\(executableSuffix)") +cmd += ["-o", compiledManifestFile.pathString] +``` + +then later (`:840, :906-908`): + +```swift +var runCmd = [compiledManifestFile.pathString] +… +runResult = try await AsyncProcess.popen(arguments: runCmd, environment: environment) +``` + +The framework search path for `PackageDescription` comes from `self.toolchain.swiftPMLibrariesLocation.manifestLibraryPath` and is added with `-F -Xlinker -rpath -Xlinker -framework PackageDescription` (or the `-L/-lPackageDescription/-rpath` dylib variant), plus an `-target` derived from `swiftPMLibrariesLocation.manifestLibraryMinimumDeploymentTarget` (`Sources/PackageLoading/ManifestLoader.swift:721-755`). Tuist by contrast (a) skips the explicit `-target` (lets `swift` interpreter pick its default), (b) does not write an intermediate executable to disk, (c) does not use `-Xlinker -rpath` for the manifest, and (d) wraps the whole thing in `sandbox-exec`. The net effect is the same — JSON on stdout, host decodes — but Tuist's path is one process and one disk-write fewer per manifest load (offset by the heavier helpers cache). + +The framework-search-path mechanics are the most directly transferable to a SyntaxKit equivalent: both tools resolve a *bundled, versioned* library that ships next to the CLI (Tuist's `ResourceLocator`, SwiftPM's `swiftPMLibrariesLocation`) and pass it via `-I/-L/-F + -framework ` (or `-l` for dylib distributions) to a single Swift compiler invocation that *also* takes the user's manifest path as a positional argument. + +## Open questions / things I couldn't determine + +- **Exact `CommandRunner.capture` semantics.** Call sites read but not implementation; *assuming* it returns stdout — the token-scanning code clearly operates on stdout-style content, but it isn't verified whether `print(…)` logs from user code (stdout) and compiler errors (stderr) end up in the same string. Inferred from `logUnexpectedImportErrorIfNeeded`'s use of `CommandError.terminated(_, standardError, command)` (`ManifestLoader.swift:577`) that stderr is captured separately. +- **Timeout.** No `terminate(after:)` / `timeout:` wiring around the manifest-spawn. If the user manifest infinite-loops, Tuist appears to wait forever. Not 100% certain because the `Command` package is a separate dependency not opened here. +- **Linux behavior.** The sandbox branch is `#if os(macOS)`; on Linux the manifest runs un-sandboxed (`ManifestLoader.swift:522-524`). Did not check whether `ResourceLocator` finds the framework on Linux or only the dylib — the `ProjectDescriptionSearchPaths.Style.commandLine` branch (which derives `-I` from a sibling `Modules/` directory) suggests Linux relies on `libProjectDescription.dylib + Modules/ProjectDescription.swiftmodule`. +- **Module cache.** SwiftPM passes `-module-cache-path` (`swift-package-manager Sources/PackageLoading/ManifestLoader.swift:759-761`); Tuist does **not** set one for the manifest invocation, so the manifest run inherits the default global Swift module cache. Significance for SyntaxKit's use case not chased. +- **`Config.swift` / `Tuist.swift` special path.** Both fall through `loadConfig` → `loadManifest(.config, …, disableSandbox: true)` → same `swift` invocation, but skip helpers entirely (`ManifestLoader.swift:407-408`). Not verified whether `Tuist.swift` accepts a different framework name (the switch in `buildArguments` lumps `.config` under `frameworkName = "ProjectDescription"`). diff --git a/Docs/skit.md b/Docs/skit.md new file mode 100644 index 00000000..1832afcd --- /dev/null +++ b/Docs/skit.md @@ -0,0 +1,177 @@ +# `skit` — a SyntaxKit CLI for config-driven Swift codegen + +`skit` is a small CLI that takes a SyntaxKit DSL file as input and writes Swift source out the other side. The vision: pure data — JSON, YAML, your own format — drives a manifest written in the SyntaxKit DSL, and `skit` materializes it into idiomatic Swift you check in alongside everything else. Less hand-maintenance, fewer drift bugs. + +This doc walks through how `skit` is built. Two verbs, one wrap-and-spawn pipeline, an output cache, and a careful toolchain story underneath. Where there are sharp edges, this doc names them. + +## Two verbs + +``` +skit run Input.swift # SyntaxKit DSL → Swift source on stdout +skit run Input.swift -o Out.swift +skit run InputDir/ -o OutDir/ # walk **/*.swift and mirror rendered output +skit parse < Input.swift # Swift source → JSON syntax tree +``` + +`run` is the default subcommand. `skit Input.swift` is shorthand for `skit run Input.swift`. `parse` is a one-shot for the inverse direction (Swift source → SwiftSyntax tree as JSON) — useful for tooling that wants to introspect existing code. + +The remainder of this doc is about `run`, which is where the interesting design decisions live. + +## How `skit run` works + +A `.swift` input file looks like a SyntaxKit DSL expression at the top level: + +```swift +// Models.swift +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +``` + +The input is not a complete Swift program. It has no `@main`, no `print`, no `let root = …`. `skit run` adds the boilerplate by wrapping the input in a `Group { … }` builder and a top-level `print` that renders the result: + +```swift +// What `skit run` writes to a temp file before spawning `swift`: +import SyntaxKit + +let __skit_root = Group { +#sourceLocation(file: "/path/to/Models.swift", line: 3) +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +#sourceLocation() +} + +print(__skit_root.generateCode()) +``` + +`skit` then spawns `swift` (the script-mode interpreter, not `swiftc`) on this wrapped file, captures stdout, and writes the result to the user's chosen output target. + +Three things deserve a closer look: + +**Imports get hoisted.** The wrapper has to start with `import SyntaxKit` so the DSL types are available. Any additional `import`s the user writes need to live at the top of the file, not inside `Group { … }`. `skit` parses the input with SwiftSyntax, peels off the leading `import` declarations, and lifts them into the wrapper preamble. Anything else (declarations, expressions, top-level types) stays in the body. + +**`#sourceLocation` keeps diagnostics readable.** When the spawned `swift` emits a compile error, it reports a line number in the wrapped temp file, which is meaningless to the user. The `#sourceLocation` directive remaps body diagnostics back to the original input path and line. Errors in the wrapper preamble (the `import` block, the `Group { … }` opening) still reference the temp file — `skit` rewrites occurrences of the temp path in stderr to the input path as a fallback, so users see something coherent. + +**`swift` runs in script mode.** Running `swift Input.swift` invokes the Swift interpreter rather than going through `swiftc` + `ld`. Cold-start is around 700ms on macOS; warm spawns are around 110ms. The CLI's hot path leans into this — for batch input via `skit run InputDir/`, we spawn one `swift` per input file in parallel up to the active core count. + +## Output cache + +One layer, keyed by content hash. Lives under `~/Library/Caches/com.brightdigit.SyntaxKit/outputs//output.swift` on macOS, `$XDG_CACHE_HOME/syntaxkit/outputs//output.swift` (or `~/.cache/syntaxkit/outputs//output.swift`) on Linux. On hit, `skit` skips the `swift` spawn for an input entirely. + +The fully-rendered output of an input gets cached by a hash of (input bytes, libSyntaxKit stamp, swift version, sorted `SKIT_*`/`SYNTAXKIT_*` env vars). On hit, total wall time is around 0.14s, dominated by hash + file read. Cold miss matches the warm script-mode baseline (~0.5s). + +`--no-cache` skips the cache. + +## Toolchain stamping + +Pre-compiled Swift modules (`SyntaxKit.swiftmodule`) are tightly coupled to the compiler that built them. Even patch-level differences fail to load: + +``` +error: module compiled with Swift 6.3 cannot be imported by the Swift 6.3.2 compiler +``` + +If `skit`'s bundled `lib/SyntaxKit.swiftmodule` doesn't match the user's `swift`, the spawned interpreter emits exactly this diagnostic and refuses to compile the wrapped input. The user is left staring at a cryptic message that doesn't name the actual problem. + +`skit` mitigates this by recording the build toolchain at bundle time. `Scripts/build-skit.sh` writes `lib/swift-version.txt` containing the output of `swift --version`. On startup, `skit run` reads the stamp and compares it to a freshly-captured local `swift --version`. Three paths: + +- **Match.** Proceed silently. +- **Mismatch.** Print a clear error naming both versions and the rebuild command, exit 2. Skip with `--no-toolchain-check`. +- **Missing stamp.** Print a one-line note and proceed. Older bundles built before this check existed shouldn't break. + +The comparison is exact-string match. Patch-level drift broke the originating bug, so anything less strict would just defer the failure to the cryptic message we're trying to avoid. + +**What this doesn't do**: it doesn't fix the mismatch, just surfaces it. The fix is to re-run the release script with the user's current toolchain. The auto-rebuild path — bundle SyntaxKit sources alongside the prebuilt module and recompile transparently when the stamp doesn't match — is tracked as [issue #157](https://github.com/brightdigit/SyntaxKit/issues/157). + +## Timeout watchdog + +The spawned `swift` is the only unbounded piece of `skit run`'s hot path. The wrap step is microseconds. The output cache hits or misses in milliseconds. But the spawn itself runs *user code* — and that code is allowed to be arbitrarily slow, recursive, or stuck. + +`skit run` defaults to a 60s per-input timeout. On expiry it sends `SIGTERM`, gives a 5s grace, then `SIGKILL`. The wrapped input exits with code 124 — POSIX `timeout(1)`'s convention. `--timeout ` overrides the default; `--timeout 0` disables the watchdog entirely (useful for debugging genuinely long codegen). + +The implementation is `DispatchSemaphore.wait(timeout: deadline)` paired with a `process.terminationHandler` that signals on child exit. The Linux Foundation `Process.waitUntilExit()` hangs on already-exited children on some configurations, which is why `skit` uses the semaphore-based wait everywhere. Same story for pipe drains — sequential reads after the child exits can deadlock when either pipe (~64 KB buffer on Linux) fills before exit, so both pipes drain concurrently via `DispatchGroup`. + +## Sharp edges + +### `if`-in-`Group` crashes the Swift type-checker — [#158](https://github.com/brightdigit/SyntaxKit/issues/158) + +`CodeBlockBuilderResult` declares all the methods needed for `if`/`else` in a result builder (`buildEither`, `buildOptional`, `buildArray`), but using them surfaces a Swift type-checker bug: + +```swift +let _ = Group { + if true { // ← error: failed to produce diagnostic for expression + Struct("A") { … } + } +} +``` + +This is a Swift compiler bug, not a `skit` bug. The workaround is to hoist the conditional into a plain Swift function that uses **plain Swift `if`/`else`** (not a `Group { if … }` body) to return one of two `CodeBlock`s: + +```swift +func optionalDebugField(_ include: Bool) -> any CodeBlock { + if include { + return Variable(.let, name: "debug", type: "Bool") + } else { + return Group {} // empty Group as the "no-op" branch — there's no + // public EmptyCodeBlock type + } +} + +Struct("Config") { + Variable(.let, name: "name", type: "String") + optionalDebugField(buildIsDebug) +} +``` + +The helper function itself can't use `Group { if … }` either — same crash. Plain Swift control flow only. + +### `@main` and top-level decl attributes don't work + +`@main` and other decl attributes (like top-level `@available`) at the start of the input fail to compile. The wrapper places the user's body inside `Group { … }`, where these attributes try to bind to a function-call expression rather than a declaration. Examples: + +```swift +@main // ❌ error: expected declaration +Struct("Person") { … } + +@available(iOS 17, *) // ❌ error: expected declaration +Struct("ModernView") { … } +``` + +The DSL has its own mechanism for attribute attachment — call `.attribute("Published")`, `.attribute("available", arguments: ["iOS 17", "*"])` on the `Variable`/`Struct`/etc. — which renders the attribute in the *output*, not on the DSL expression itself. That's the path users should take. + +### Comments don't carry through + +Swift comments in the input file (`// MARK: - Models`, block comments, inline `//` after a DSL line) don't render to the output. The input's comments are *only* for the input's author. To emit a comment in the rendered code, attach it to a `CodeBlock` via `.comment { Line(.doc, "…") }`. + +### `#if os(...)` is gen-time, not output + +A `#if os(macOS) … #endif` block in the input is evaluated when the wrapped file compiles, controlling whether the enclosed DSL expressions are part of the builder. It does *not* emit a `#if os(macOS)` directive into the rendered Swift output. If you want compile-time-conditional output, emit the `#if` lines as raw text via `VariableExp("#if os(macOS)")` (or write a small helper). + +## Platform notes + +**macOS** is the primary target. The build and release flows live in `Scripts/`; the bundle is portable across machines with the same Swift version. + +**Linux** is verified on `swift:6.0-jammy/aarch64`. One adjustment compared to macOS: the Mach-O `install_name` rewrite in `Scripts/build-skit.sh` is skipped — GNU `ld` doesn't accept the flag. The `-rpath` injection (which is what actually locates the dylib at runtime) works on both platforms. + +The Foundation.Process workarounds described earlier are Linux-driven. `Process.waitUntilExit()` blocks indefinitely on already-exited children on `swift:6.0-jammy/aarch64` — `skit` uses `DispatchSemaphore` everywhere a child wait is needed, and drains stdout/stderr pipes concurrently to avoid deadlocks when either pipe buffer fills. + +**Windows** is not supported. + +## What's deferred + +A few things were considered for v1 and explicitly punted: + +- **Auto-rebuild on toolchain mismatch** — [#157](https://github.com/brightdigit/SyntaxKit/issues/157). Today the user gets a clear error and a rebuild command; tomorrow `skit` should rebuild `libSyntaxKit` from bundled sources transparently and cache the result per Swift version. The stamp-and-detect path shipped in this release is the foundation; the rebuild fallback is the natural next step. +- **Multi-file output from a single input** — out of scope. The folder mode handles N-inputs → N-outputs; if you need fan-out from one logical generator, split it into multiple input files. +- **Sandboxing the spawned `swift`** — out of scope. The threat model is "you ran your own code", same as `swift Input.swift` by hand. +- **HTTP / web-server form** — out of scope for the CLI. A long-lived server could share a warm interpreter and the caches across requests, but that's a different shape with its own isolation questions. Revisit later. + +## Reference + +- [`Sources/skit/README.md`](../Sources/skit/README.md) — per-target quick reference (flag table). +- [`Scripts/build-skit.sh`](../Scripts/build-skit.sh) — release-bundle builder (use `--debug` for the fast iteration variant). +- [`Docs/research/tuist-manifest-pipeline.md`](research/tuist-manifest-pipeline.md) — the manifest-pipeline pattern this CLI borrows from. +- [Issue #154](https://github.com/brightdigit/SyntaxKit/issues/154) — original tracking issue. +- [Issue #157](https://github.com/brightdigit/SyntaxKit/issues/157), [Issue #158](https://github.com/brightdigit/SyntaxKit/issues/158) — follow-ups. diff --git a/Examples/Completed/attributes/dsl.swift b/Examples/Completed/attributes/dsl.swift index daf5416f..e7d453a7 100644 --- a/Examples/Completed/attributes/dsl.swift +++ b/Examples/Completed/attributes/dsl.swift @@ -1,7 +1,10 @@ Class("Foo") { - Variable(.var, name: "bar", type: "String", defaultValue: "bar").attribute("Published") + Variable(.var, name: "bar", type: "String", equals: "bar").attribute("Published") Function("bar") { - print("bar") + Call("print") { + ParameterExp(unlabeled: Literal.string("bar")) + } }.attribute("available", arguments: ["iOS 17.0", "*"]) Function("baz") { -}.attribute("objc")}.attribute("objc") \ No newline at end of file + }.attribute("objc") +} diff --git a/Examples/Completed/blackjack/dsl.swift b/Examples/Completed/blackjack/dsl.swift index d0af68d4..f894b515 100644 --- a/Examples/Completed/blackjack/dsl.swift +++ b/Examples/Completed/blackjack/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Example of generating a BlackjackCard struct with a nested Suit enum -let structExample = Struct("BlackjackCard") { +Struct("BlackjackCard") { Enum("Suit") { EnumCase("spades").equals("♠") EnumCase("hearts").equals("♡") @@ -28,29 +28,29 @@ let structExample = Struct("BlackjackCard") { Variable(.let, name: "first", type: "Int") Variable(.let, name: "second", type: "Int?") } - ComputedProperty("values") { + ComputedProperty("values", type: "Values") { Switch("self") { SwitchCase(".ace") { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "1") - Parameter(name: "second", value: "11") + ParameterExp(name: "first", value: "1") + ParameterExp(name: "second", value: "11") } } } SwitchCase(".jack", ".queen", ".king") { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "10") - Parameter(name: "second", value: "nil") + ParameterExp(name: "first", value: "10") + ParameterExp(name: "second", value: "nil") } } } Default { - Return{ + Return { Init("Values") { - Parameter(name: "first", value: "self.rawValue") - Parameter(name: "second", value: "nil") + ParameterExp(name: "first", value: "self.rawValue") + ParameterExp(name: "second", value: "nil") } } } @@ -62,17 +62,14 @@ let structExample = Struct("BlackjackCard") { Variable(.let, name: "rank", type: "Rank") Variable(.let, name: "suit", type: "Suit") - ComputedProperty("description") { - VariableDecl(.var, name: "output", equals: "suit is \(suit.rawValue),") - PlusAssign("output", " value is \(rank.values.first)") + ComputedProperty("description", type: "String") { + Variable(.var, name: "output", equals: "suit is \\(suit.rawValue),") + PlusAssign("output", " value is \\(rank.values.first)") If(Let("second", "rank.values.second"), then: { - PlusAssign("output", " or \(second)") + PlusAssign("output", " or \\(second)") }) Return { VariableExp("output") } } } - -// Generate and print the code -print(structExample.generateCode()) diff --git a/Examples/Completed/card_game/dsl.swift b/Examples/Completed/card_game/dsl.swift index f6f9bdd0..0374e512 100644 --- a/Examples/Completed/card_game/dsl.swift +++ b/Examples/Completed/card_game/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Example of generating a BlackjackCard struct with a nested Suit enum -let structExample = Group { +Group { Struct("Card") { Variable(.let, name: "rank", type: "Rank") .comment{ @@ -39,31 +39,31 @@ let structExample = Group { Variable(.let, name: "first", type: "Int") Variable(.let, name: "second", type: "Int?") } - ComputedProperty("description") { + ComputedProperty("description", type: "String") { Switch("self") { SwitchCase(".jack") { - Return{ - Literal("\"J\"") + Return { + Literal.string("J") } } SwitchCase(".queen") { - Return{ - Literal("\"Q\"") + Return { + Literal.string("Q") } } SwitchCase(".king") { - Return{ - Literal("\"K\"") + Return { + Literal.string("K") } } SwitchCase(".ace") { - Return{ - Literal("\"A\"") + Return { + Literal.string("A") } } Default { - Return{ - Literal("\\(rawValue)") + Return { + Literal.string("\\(rawValue)") } } } @@ -92,5 +92,3 @@ let structExample = Group { } } -// Generate and print the code -print(structExample.generateCode()) diff --git a/Examples/Completed/concurrency/dsl.swift b/Examples/Completed/concurrency/dsl.swift index 300a4609..d0f66dd8 100644 --- a/Examples/Completed/concurrency/dsl.swift +++ b/Examples/Completed/concurrency/dsl.swift @@ -1,48 +1,51 @@ Enum("VendingMachineError") { - Case("invalidSelection") - Case("insufficientFunds").associatedValue("coinsNeeded", type: "Int") - Case("outOfStock") + EnumCase("invalidSelection") + EnumCase("insufficientFunds").associatedValue("coinsNeeded", type: "Int") + EnumCase("outOfStock") } +.inherits("Error") Class("VendingMachine") { - Variable(.var, name: "inventory", equals: Literal.dictionary(Dictionary(uniqueKeysWithValues: [ - ("Candy Bar", Item(price: 12, count: 7)), - ("Chips", Item(price: 10, count: 4)), - ("Pretzels", Item(price: 7, count: 11)) - ]))) + // Dictionary values are `Init`-expressions of an external `Item` type, which + // Literal.dictionary's typed cases can't represent — emit the literal as raw + // Swift source via VariableExp. + Variable(.var, name: "inventory") { + VariableExp(""" + [ + "Candy Bar": Item(price: 12, count: 7), + "Chips": Item(price: 10, count: 4), + "Pretzels": Item(price: 7, count: 11) + ] + """) + } Variable(.var, name: "coinsDeposited", equals: 0) - Function("vend"){ + Function("vend") { Parameter("name", labeled: "itemNamed", type: "String") } _: { - Guard("let item = inventory[itemNamed]") else: { - Throw( - EnumValue("VendingMachineError", case: "invalidSelection") - ) + Guard { + Let("item", "inventory[itemNamed]") + } else: { + Throw(VariableExp("VendingMachineError.invalidSelection")) } - Guard("item.count > 0") else: { - Throw( - EnumValue("VendingMachineError", case: "outOfStock") - ) + Guard { + Infix(">", lhs: VariableExp("item.count"), rhs: Literal.integer(0)) + } else: { + Throw(VariableExp("VendingMachineError.outOfStock")) } - Guard("item.price <= coinsDeposited") else: { - Throw( - EnumValue("VendingMachineError", case: "insufficientFunds"){ - ParameterExp("coinsNeeded", value: Infix("-"){ - VariableExp("item").property("price") - VariableExp("coinsDeposited") - }) - } - ) + Guard { + Infix("<=", lhs: VariableExp("item.price"), rhs: VariableExp("coinsDeposited")) + } else: { + Throw(VariableExp( + "VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)" + )) } - Infix("-=", "coinsDeposited", VariableExp("item").property("price")) - Variable("newItem", equals: VariableExp("item")) - Infix("-=", "newItem.count", 1) - Assignment("inventory[itemNamed]", .ref("newItem")) - Call("print", "Dispensing \\(itemNamed)") - } + Infix("-=", lhs: VariableExp("coinsDeposited"), rhs: VariableExp("item.price")) + Variable(.var, name: "newItem") { VariableExp("item") } + Infix("-=", lhs: VariableExp("newItem.count"), rhs: Literal.integer(1)) + Assignment("inventory[itemNamed]", VariableExp("newItem")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Dispensing \\(itemNamed)")) + } + }.throws() } - - - - diff --git a/Examples/Completed/conditionals/dsl.swift b/Examples/Completed/conditionals/dsl.swift index 70263e0c..ef057023 100644 --- a/Examples/Completed/conditionals/dsl.swift +++ b/Examples/Completed/conditionals/dsl.swift @@ -4,31 +4,31 @@ Group { Line("Simple if statement") } If { - Infix("temperature", ">", 30) + Infix(">", lhs: VariableExp("temperature"), rhs: Literal.integer(30)) } then: { - Call("print", "It's hot outside!") + Call("print") { ParameterExp(unlabeled: Literal.string("It's hot outside!")) } } Variable(.let, name: "score", equals: Literal.integer(85)) .comment { Line("If-else statement") } If { - Infix("score", ">=", 90) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { - Call("print", "Excellent!") + Call("print") { ParameterExp(unlabeled: Literal.string("Excellent!")) } } else: { If { - Infix("score", ">=", 80) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { - Call("print", "Good job!") + Call("print") { ParameterExp(unlabeled: Literal.string("Good job!")) } } If { - try Infix("score", ">=", 70) + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(70)) } then: { - Call("print", "Passing") + Call("print") { ParameterExp(unlabeled: Literal.string("Passing")) } } Then { - Call("print", "Needs improvement") + Call("print") { ParameterExp(unlabeled: Literal.string("Needs improvement")) } } } @@ -40,9 +40,9 @@ Group { If(Let("actualNumber", Init("Int") { ParameterExp(name: "", value: "possibleNumber") }), then: { - Call("print", "The string \"\\(possibleNumber)\" has an integer value of \\(actualNumber)") + Call("print") { ParameterExp(unlabeled: Literal.string("The string \"\\(possibleNumber)\" has an integer value of \\(actualNumber)")) } }, else: { - Call("print", "The string \"\\(possibleNumber)\" could not be converted to an integer") + Call("print") { ParameterExp(unlabeled: Literal.string("The string \"\\(possibleNumber)\" could not be converted to an integer")) } }) Variable(.let, name: "possibleName", type: "String?", equals: Literal.string("John")).withExplicitType() @@ -54,14 +54,16 @@ Group { Let("name", "possibleName") Let("age", "possibleAge") } then: { - Call("print", "\\(name) is \\(age) years old") + Call("print") { ParameterExp(unlabeled: Literal.string("\\(name) is \\(age) years old")) } } - Function("greet", parameters: [Parameter("person", type: "[String: String]")]) { + Function("greet") { + Parameter(name: "person", type: "[String: String]") + } _: { Guard { Let("name", "person[\"name\"]") } else: { - Call("print", "No name provided") + Call("print") { ParameterExp(unlabeled: Literal.string("No name provided")) } } Guard { Let("age", "person[\"age\"]") @@ -69,9 +71,9 @@ Group { ParameterExp(name: "", value: "age") }) } else: { - Call("print", "Invalid age provided") + Call("print") { ParameterExp(unlabeled: Literal.string("Invalid age provided")) } } - Call("print", "Hello \\(name), you are \\(ageInt) years old") + Call("print") { ParameterExp(unlabeled: Literal.string("Hello \\(name), you are \\(ageInt) years old")) } } }.comment { Line("MARK: - Guard Statements") @@ -104,26 +106,26 @@ Switch("approximateCount") { Assignment("naturalCount", Literal.string("many")) } } -Call("print", "There are \\(naturalCount) \\(countedThings).") +Call("print") { ParameterExp(unlabeled: Literal.string("There are \\(naturalCount) \\(countedThings).")) } Variable(.let, name: "somePoint", type: "(Int, Int)", equals: VariableExp("(1, 1)"), explicitType: true) .comment { Line("Switch with tuple matching") } Switch("somePoint") { SwitchCase(Tuple.pattern([0, 0])) { - Call("print", "(0, 0) is at the origin") + Call("print") { ParameterExp(unlabeled: Literal.string("(0, 0) is at the origin")) } } SwitchCase(Tuple.pattern([nil, 0])) { - Call("print", "(\(somePoint.0), 0) is on the x-axis") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), 0) is on the x-axis")) } } SwitchCase(Tuple.pattern([0, nil])) { - Call("print", "(0, \(somePoint.1)) is on the y-axis") + Call("print") { ParameterExp(unlabeled: Literal.string("(0, \\(somePoint.1)) is on the y-axis")) } } SwitchCase(Tuple.pattern([(-2...2), (-2...2)])) { - Call("print", "(\(somePoint.0), \(somePoint.1)) is inside the box") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), \\(somePoint.1)) is inside the box")) } } Default { - Call("print", "(\(somePoint.0), \(somePoint.1)) is outside of the box") + Call("print") { ParameterExp(unlabeled: Literal.string("(\\(somePoint.0), \\(somePoint.1)) is outside of the box")) } } } Variable(.let, name: "anotherPoint", type: "(Int, Int)", equals: VariableExp("(2, 0)"), explicitType: true) @@ -131,21 +133,21 @@ Variable(.let, name: "anotherPoint", type: "(Int, Int)", equals: VariableExp("(2 Line("Switch with value binding") } Switch("anotherPoint") { - SwitchCase(Tuple.pattern([.let("x"), 0])) { - Call("print", "on the x-axis with an x value of \(x)") + SwitchCase(Tuple.pattern([Pattern.let("x"), 0])) { + Call("print") { ParameterExp(unlabeled: Literal.string("on the x-axis with an x value of \\(x)")) } } - SwitchCase(Tuple.pattern([0, .let("y")])) { - Call("print", "on the y-axis with a y value of \(y)") + SwitchCase(Tuple.pattern([0, Pattern.let("y")])) { + Call("print") { ParameterExp(unlabeled: Literal.string("on the y-axis with a y value of \\(y)")) } } - SwitchCase(Tuple.pattern([.let("x"), .let("y")])) { - Call("print", "somewhere else at (\(x), \(y))") + SwitchCase(Tuple.pattern([Pattern.let("x"), Pattern.let("y")])) { + Call("print") { ParameterExp(unlabeled: Literal.string("somewhere else at (\\(x), \\(y))")) } } } Variable(.let, name: "integerToDescribe", equals: 5) -Variable(.var, name: "description", equals: "The number \(integerToDescribe) is") +Variable(.var, name: "description", equals: "The number \\(integerToDescribe) is") Switch("integerToDescribe") { SwitchCase(2, 3, 5, 7, 11, 13, 17, 19) { PlusAssign("description", "a prime number, and also") @@ -155,68 +157,62 @@ Switch("integerToDescribe") { PlusAssign("description", "an integer.") } } -Call("print", "description") +Call("print") { ParameterExp(unlabeled: Literal.string("description")) } Variable(.let, name: "finalSquare", equals: 25) Variable(.var, name: "board", equals: Literal.array(Array(repeating: Literal.integer(0), count: 26))) -Infix("board[03]", "+=", 8) -Infix("board[06]", "+=", 11) -Infix("board[09]", "+=", 9) -Infix("board[10]", "+=", 2) -Infix("board[14]", "-=", 10) -Infix("board[19]", "-=", 11) -Infix("board[22]", "-=", 2) -Infix("board[24]", "-=", 8) +Infix("+=", lhs: VariableExp("board[03]"), rhs: Literal.integer(8)) +Infix("+=", lhs: VariableExp("board[06]"), rhs: Literal.integer(11)) +Infix("+=", lhs: VariableExp("board[09]"), rhs: Literal.integer(9)) +Infix("+=", lhs: VariableExp("board[10]"), rhs: Literal.integer(2)) +Infix("-=", lhs: VariableExp("board[14]"), rhs: Literal.integer(10)) +Infix("-=", lhs: VariableExp("board[19]"), rhs: Literal.integer(11)) +Infix("-=", lhs: VariableExp("board[22]"), rhs: Literal.integer(2)) +Infix("-=", lhs: VariableExp("board[24]"), rhs: Literal.integer(8)) Variable(.var, name: "square", equals: 0) Variable(.var, name: "diceRoll", equals: 0) -While { - try Infix("square", "!=", "finalSquare") -} then: { - Assignment("diceRoll", "+", 1) +While(Infix("!=", lhs: VariableExp("square"), rhs: VariableExp("finalSquare"))) { + Infix("+=", lhs: VariableExp("diceRoll"), rhs: Literal.integer(1)) If { - try Infix("diceRoll", "==", 7) + Infix("==", lhs: VariableExp("diceRoll"), rhs: Literal.integer(7)) } then: { Assignment("diceRoll", 1) } - Switch(try Infix("square", "+", "diceRoll")) { + Switch(Infix("+", lhs: VariableExp("square"), rhs: VariableExp("diceRoll"))) { SwitchCase("finalSquare") { Break() } - SwitchCase(try Infix("newSquare", ">", "finalSquare")) { + SwitchCase(Infix(">", lhs: VariableExp("newSquare"), rhs: VariableExp("finalSquare"))) { Continue() } Default { - try Infix("square", "+=", "diceRoll") - try Infix("square", "+=", "board[square]") + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("diceRoll")) + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("board[square]")) } } } -Call("print", "\n=== For-in with Enumerated ===") +Call("print") { ParameterExp(unlabeled: Literal.string("\n=== For-in with Enumerated ===")) } .comment { Line("MARK: - For Loops") Line("For-in loop with enumerated() to get index and value") } -For { - Tuple.pattern([VariableExp("index"), VariableExp("name")]) -} in: { - VariableExp("names").call("enumerated") -} then: { - Call("print", "Index: \\(index), Name: \\(name)") -} +For(Tuple.patternCodeBlock([VariableExp("index"), VariableExp("name")]), + in: VariableExp("names").call("enumerated"), + then: { + Call("print") { ParameterExp(unlabeled: Literal.string("Index: \\(index), Name: \\(name)")) } + }) -Call("print", "\n=== For-in with Where Clause ===") +Call("print") { ParameterExp(unlabeled: Literal.string("\n=== For-in with Where Clause ===")) } .comment { Line("For-in loop with where clause") } -For { - VariableExp("numbers") -} in: { - Literal.array([Literal.integer(1), Literal.integer(2), Literal.integer(3), Literal.integer(4), Literal.integer(5), Literal.integer(6), Literal.integer(7), Literal.integer(8), Literal.integer(9), Literal.integer(10)]) -} where: { - try Infix("number", "%", 2) -} then: { - Call("print", "Even number: \\(number)") -} +For(VariableExp("number"), + in: VariableExp("numbers"), + then: { + If(VariableExp("number % 2 == 0"), then: { + Call("print") { ParameterExp(unlabeled: Literal.string("Even number: \\(number)")) } + }) + }) diff --git a/Examples/Completed/for_loops/dsl.swift b/Examples/Completed/for_loops/dsl.swift index db060096..74b1d1f9 100644 --- a/Examples/Completed/for_loops/dsl.swift +++ b/Examples/Completed/for_loops/dsl.swift @@ -40,13 +40,11 @@ Group { Variable(.let, name: "numbers", equals: Literal.array([Literal.integer(1), Literal.integer(2), Literal.integer(3), Literal.integer(4), Literal.integer(5), Literal.integer(6), Literal.integer(7), Literal.integer(8), Literal.integer(9), Literal.integer(10)])) For(VariableExp("number"), in: VariableExp("numbers"), where: { - try Infix("==") { - try Infix("%") { - VariableExp("number") - Literal.integer(2) - } - Literal.integer(0) - } + Infix("==", + lhs: Infix("%", + lhs: VariableExp("number"), + rhs: Literal.integer(2)), + rhs: Literal.integer(0)) }, then: { Call("print") { ParameterExp(unlabeled: "\"Even number: \\(number)\"") diff --git a/Examples/Completed/macro_tutorial/dsl.swift b/Examples/Completed/macro_tutorial/dsl.swift index 490051fa..0cea4cf3 100644 --- a/Examples/Completed/macro_tutorial/dsl.swift +++ b/Examples/Completed/macro_tutorial/dsl.swift @@ -1,276 +1,217 @@ import SyntaxKit // MARK: - Macro Tutorial DSL Examples -// This file shows how SyntaxKit DSL would be used to generate the macro examples - -// MARK: - Example 1: Extension Macro Generation -// This shows how to generate the extension that @MyMacro would create - -let colorExtension = Extension("Color") { - // Add a type alias - TypeAlias("MyType", equals: "String") - - // Add a static property with case names - Variable(.let, name: "myProperty", equals: ["red", "green", "blue"]).static() - - // Add a computed property - ComputedProperty("description") { - Return { - VariableExp("myProperty.joined(separator: \", \")") +// +// This file demonstrates the SyntaxKit DSL patterns one would use to generate +// the kind of code a Swift macro produces. Each example is a top-level block; +// skitrun concatenates them into a single rendered file. (The original +// version of this file used per-example `let` bindings and a final `print`, +// which skitrun's wrapper doesn't accept — top-level expressions only.) + +Group { + // MARK: Example 1 — Extension Macro Generation + Extension("Color") { + TypeAlias("MyType", equals: "String") + Variable(.let, name: "myProperty", equals: ["red", "green", "blue"]).static() + ComputedProperty("description", type: "String") { + Return { VariableExp("myProperty.joined(separator: \", \")") } + } + }.inherits("MyProtocol") + + // MARK: Example 2 — Peer Macro Generation + // + // The original example declared `init(value: Color) { self.value = value }` + // via `Init { Parameter(...) }`, which isn't part of the current public DSL + // (Init is an expression-only call here). Emit the initializer body as raw + // Swift instead. + Struct("ColorWrapper") { + Variable(.let, name: "value", type: "Color") + VariableExp(""" + init(value: Color) { + self.value = value + } + """) + ComputedProperty("description", type: "String") { + Return { VariableExp("value.description") } } } -}.inherits("MyProtocol") - -// MARK: - Example 2: Peer Macro Generation -// This shows how to generate the wrapper struct that @MyMacro would create -let colorWrapper = Struct("ColorWrapper") { - Variable(.let, name: "value", type: "Color") - - Init { - Parameter(name: "value", type: "Color") + // MARK: Example 3 — Freestanding Expression Macro Generation + Tuple { + VariableExp("42 + 8") + Literal.string("42 + 8") } - - ComputedProperty("description") { - Return { - VariableExp("value.description") - } - } -} -// MARK: - Example 3: Freestanding Expression Macro Generation -// This shows how to generate the tuple that #stringify would create + // MARK: Example 4 — Complex Extension Generation + Extension("User") { + Enum("Status") { + EnumCase("active").equals("active") + EnumCase("inactive").equals("inactive") + EnumCase("pending").equals("pending") + }.inherits("String") + + ComputedProperty("isValid", type: "Bool") { + If(VariableExp("status == .active"), then: { + Return { Literal.boolean(true) } + }, else: { + Return { Literal.boolean(false) } + }) + } -let stringifyResult = Tuple { - VariableExp("42 + 8") - Literal.string("42 + 8") -} + Function("updateStatus") { + Parameter(name: "newStatus", type: "Status") + } _: { + Assignment("status", VariableExp("newStatus")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Status updated to \\(newStatus)")) + } + } -// MARK: - Example 4: Complex Extension Generation -// This shows how to generate a complex extension with multiple members + Function("createDefault") { + } _: { + Return { + Init("User") { + ParameterExp(name: "status", value: VariableExp(".pending")) + ParameterExp(name: "name", value: Literal.string("Default")) + } + } + }.static() + }.inherits("Identifiable", "Codable") + + // MARK: Example 5 — Error Handling Structure + Enum("MacroError") { + EnumCase("onlyWorksWithEnums") + EnumCase("invalidCaseName").associatedValue("name", type: "String") + EnumCase("missingRawValue") + }.inherits("Error", "CustomStringConvertible") + + // MARK: Example 8 — Protocol Generation + Protocol("MyProtocol") { + PropertyRequirement("description", type: "String", access: .get) + } -let userExtension = Extension("User") { - // Add a nested enum - Enum("Status") { - EnumCase("active").equals("active") - EnumCase("inactive").equals("inactive") - EnumCase("pending").equals("pending") - }.inherits("String") - - // Add a computed property with complex logic - ComputedProperty("isValid") { - If(VariableExp("status == .active"), then: { - Return { Literal.boolean(true) } - }, else: { - Return { Literal.boolean(false) } + // MARK: Example 9 — Complex Control Flow Generation + Function("processData") { + Parameter(name: "data", type: "[String]") + } _: { + Variable(.var, name: "result", equals: "[]") + For(VariableExp("item"), in: VariableExp("data"), then: { + If(VariableExp("item.hasPrefix(\"test\")"), then: { + Call("result.append") { + ParameterExp(unlabeled: VariableExp("item.uppercased()")) + } + }, else: { + Call("result.append") { + ParameterExp(unlabeled: VariableExp("item.lowercased()")) + } + }) }) + Return { VariableExp("result") } } - - // Add a method with parameters - Function("updateStatus", parameters: [Parameter("newStatus", type: "Status")]) { - Assignment("status", VariableExp("newStatus")) - Call("print") { - ParameterExp(unlabeled: "\"Status updated to \\(newStatus)\"") - } - } - - // Add a static method - Function("createDefault", parameters: []) { - Return { - Init("User") { - Parameter(name: "status", value: ".pending") - Parameter(name: "name", value: "\"Default\"") + + // MARK: Example 10 — Nested Structure Generation + Struct("ComplexStruct") { + Enum("NestedEnum") { + EnumCase("case1") + EnumCase("case2").equals("value2") + }.inherits("String") + + Struct("NestedStruct") { + Variable(.let, name: "id", type: "UUID") + Variable(.var, name: "name", type: "String") + + VariableExp(""" + init(name: String) { + self.id = UUID() + self.name = name + } + """) + + ComputedProperty("displayName", type: "String") { + Return { + VariableExp("name.isEmpty ? \"Unknown\" : name") + } } } - }.static() - -}.inherits("Identifiable", "Codable") - -// MARK: - Example 5: Error Handling Structure -// This shows how to generate error handling code -let macroError = Enum("MacroError") { - EnumCase("onlyWorksWithEnums") - EnumCase("invalidCaseName").associatedValue("name", type: "String") - EnumCase("missingRawValue") -}.inherits("Error", "CustomStringConvertible") + Variable(.let, name: "enumValue", type: "NestedEnum") + Variable(.var, name: "structValue", type: "NestedStruct") -// MARK: - Example 6: Test Code Generation -// This shows how to generate test code for macros - -let testFunction = Function("testExtensionMacro", parameters: []) { - Call("assertMacroExpansion") { - ParameterExp(name: "input", value: "\"\"\"\n@MyMacro\nenum Color: String {\n case red = \"red\"\n case blue = \"blue\"\n}\n\"\"\"") - ParameterExp(name: "expected", value: "\"\"\"\nenum Color: String {\n case red = \"red\"\n case blue = \"blue\"\n}\n\nextension Color: MyProtocol {\n typealias MyType = String\n static let myProperty = [\"red\", \"blue\"]\n var description: String {\n return myProperty.joined(separator: \", \")\n }\n}\n\nstruct ColorWrapper {\n let value: Color\n init(value: Color) {\n self.value = value\n }\n var description: String {\n return value.description\n }\n}\n\"\"\"") - ParameterExp(name: "macros", value: "[\"MyMacro\": MyMacro.self]") + Function("updateName") { + Parameter(name: "newName", type: "String") + } _: { + Assignment("structValue.name", VariableExp("newName")) + Call("print") { + ParameterExp(unlabeled: Literal.string("Name updated to: \\(newName)")) + } + } } -}.throws() -// MARK: - Example 7: Integration Example -// This shows how to mix SyntaxKit with raw SwiftSyntax - -let baseStruct = Struct("Generated") { - Variable(.let, name: "value", type: "String") -} - -// Convert to SwiftSyntax and modify (this would be done in the macro) -// var structDecl = baseStruct.syntax.as(StructDeclSyntax.self)! -// structDecl = structDecl.with(\.modifiers, DeclModifierListSyntax { -// DeclModifierSyntax(name: .keyword(.public)) -// }) - -// MARK: - Example 8: Protocol Generation -// This shows how to generate protocols that macros might need - -let myProtocol = Protocol("MyProtocol") { - PropertyRequirement("description", type: "String", access: .get) -} - -// MARK: - Example 9: Complex Control Flow Generation -// This shows how to generate complex control flow in macros - -let complexFunction = Function("processData", parameters: [Parameter("data", type: "[String]")]) { - Variable(.var, name: "result", equals: "[]") - - For(VariableExp("item"), in: VariableExp("data"), then: { - If(VariableExp("item.hasPrefix(\"test\")"), then: { - Call("result.append") { - ParameterExp(unlabeled: "item.uppercased()") + // MARK: Example 11 — Switch Statement Generation + Function("handleStatus") { + Parameter(name: "status", type: "UserStatus") + } _: { + Switch("status") { + SwitchCase(".active") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User is active")) + } + Return { Literal.boolean(true) } } - }, else: { - Call("result.append") { - ParameterExp(unlabeled: "item.lowercased()") + SwitchCase(".inactive") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User is inactive")) + } + Return { Literal.boolean(false) } } - }) - }) - - Return { - VariableExp("result") - } -} - -// MARK: - Example 10: Nested Structure Generation -// This shows how to generate nested structures - -let complexStruct = Struct("ComplexStruct") { - // Nested enum - Enum("NestedEnum") { - EnumCase("case1") - EnumCase("case2").equals("value2") - }.inherits("String") - - // Nested struct - Struct("NestedStruct") { - Variable(.let, name: "id", type: "UUID") - Variable(.var, name: "name", type: "String") - - Init { - Parameter(name: "name", type: "String") - } - - ComputedProperty("displayName") { - Return { - VariableExp("name.isEmpty ? \"Unknown\" : name") + SwitchCase(".pending") { + Call("print") { + ParameterExp(unlabeled: Literal.string("User status is pending")) + } + Return { Literal.boolean(false) } + } + Default { + Call("print") { + ParameterExp(unlabeled: Literal.string("Unknown status")) + } + Return { Literal.boolean(false) } } } } - - // Properties - Variable(.let, name: "enumValue", type: "NestedEnum") - Variable(.var, name: "structValue", type: "NestedStruct") - - // Methods - Function("updateName", parameters: [Parameter("newName", type: "String")]) { - Assignment("structValue.name", VariableExp("newName")) - Call("print") { - ParameterExp(unlabeled: "\"Name updated to: \\(newName)\"") - } - } -} - -// MARK: - Example 11: Switch Statement Generation -// This shows how to generate switch statements in macros -let switchFunction = Function("handleStatus", parameters: [Parameter("status", type: "UserStatus")]) { - Switch("status") { - SwitchCase(".active") { + // MARK: Example 12 — Guard Statement Generation + Function("validateUser") { + Parameter(name: "user", type: "User?") + } _: { + Guard { + Let("user", "user") + } else: { Call("print") { - ParameterExp(unlabeled: "\"User is active\"") + ParameterExp(unlabeled: Literal.string("User is nil")) } - Return { Literal.boolean(true) } + Return { Literal.boolean(false) } } - SwitchCase(".inactive") { + + Guard { + Let("name", "user.name") + Let("nameLength", "name.count") + } else: { Call("print") { - ParameterExp(unlabeled: "\"User is inactive\"") + ParameterExp(unlabeled: Literal.string("Invalid user name")) } Return { Literal.boolean(false) } } - SwitchCase(".pending") { + + If(VariableExp("nameLength > 0"), then: { Call("print") { - ParameterExp(unlabeled: "\"User status is pending\"") + ParameterExp(unlabeled: Literal.string("User \\(name) is valid")) } - Return { Literal.boolean(false) } - } - Default { + Return { Literal.boolean(true) } + }, else: { Call("print") { - ParameterExp(unlabeled: "\"Unknown status\"") + ParameterExp(unlabeled: Literal.string("User name is empty")) } Return { Literal.boolean(false) } - } - } -} - -// MARK: - Example 12: Guard Statement Generation -// This shows how to generate guard statements in macros - -let guardFunction = Function("validateUser", parameters: [Parameter("user", type: "User?")]) { - Guard { - Let("user", "user") - } else: { - Call("print") { - ParameterExp(unlabeled: "\"User is nil\"") - } - Return { Literal.boolean(false) } - } - - Guard { - Let("name", "user.name") - Let("nameLength", "name.count") - } else: { - Call("print") { - ParameterExp(unlabeled: "\"Invalid user name\"") - } - Return { Literal.boolean(false) } + }) } - - If(VariableExp("nameLength > 0"), then: { - Call("print") { - ParameterExp(unlabeled: "\"User \\(name) is valid\"") - } - Return { Literal.boolean(true) } - }, else: { - Call("print") { - ParameterExp(unlabeled: "\"User name is empty\"") - } - Return { Literal.boolean(false) } - }) } - -// MARK: - Generate All Examples - -let allExamples = Group { - colorExtension - colorWrapper - stringifyResult - userExtension - macroError - testFunction - myProtocol - complexFunction - complexStruct - switchFunction - guardFunction -} - -// Print the generated code -print(allExamples.generateCode()) \ No newline at end of file diff --git a/Examples/Completed/protocols/dsl.swift b/Examples/Completed/protocols/dsl.swift index bd1794f9..d44aec90 100644 --- a/Examples/Completed/protocols/dsl.swift +++ b/Examples/Completed/protocols/dsl.swift @@ -1,7 +1,7 @@ import SyntaxKit // Generate and print the code -let generatedCode = Group { +Group { // MARK: - Protocol Definition Protocol("Vehicle") { PropertyRequirement("numberOfWheels", type: "Int", access: .get) @@ -57,8 +57,13 @@ let generatedCode = Group { }.inherits("Vehicle") // MARK: - Usage Example - VariableDecl(.let, name: "tesla", equals: "ElectricCar(brand: \"Tesla\", batteryLevel: 75.0)") - VariableDecl(.let, name: "toyota", equals: "Car(brand: \"Toyota\")") + Variable(.let, name: "tesla", equals: Init("ElectricCar") { + ParameterExp(name: "brand", value: Literal.string("Tesla")) + ParameterExp(name: "batteryLevel", value: Literal.float(75.0)) + }) + Variable(.let, name: "toyota", equals: Init("Car") { + ParameterExp(name: "brand", value: Literal.string("Toyota")) + }) // Demonstrate protocol usage Function("demonstrateVehicle") { @@ -103,4 +108,3 @@ let generatedCode = Group { } } -print(generatedCode.generateCode()) \ No newline at end of file diff --git a/Examples/Completed/swiftui/dsl.swift b/Examples/Completed/swiftui/dsl.swift index 4e2f97cb..d2822ce2 100644 --- a/Examples/Completed/swiftui/dsl.swift +++ b/Examples/Completed/swiftui/dsl.swift @@ -1,7 +1,7 @@ -Import("SwiftUI").access("public") +Import("SwiftUI").access(.public) Struct("TodoItemRow") { - Variable(.let, name: "item", type: "TodoItem").access("private") + Variable(.let, name: "item", type: "TodoItem").access(.private) Variable(.let, name: "onToggle", type: ClosureType(returns: "Void"){ @@ -10,7 +10,7 @@ Struct("TodoItemRow") { .attribute("@MainActor") .attribute("@Sendable") ) - .access("private") + .access(.private) ComputedProperty("body", type: "some View") { Init("HStack") { @@ -21,13 +21,13 @@ Struct("TodoItemRow") { ParameterExp(unlabeled: Closure{ Init("Image") { ParameterExp(name: "systemName", value: ConditionalOp( - if: VariableExp("item").property(name: "isCompleted"), + if: VariableExp("item").property("isCompleted"), then: Literal.string("checkmark.circle.fill"), else: Literal.string("circle") )) }.call("foregroundColor"){ ParameterExp(unlabeled: ConditionalOp( - if: VariableExp("item").property(name: "isCompleted"), + if: VariableExp("item").property("isCompleted"), then: EnumCase("green"), else: EnumCase("gray") )) @@ -39,7 +39,7 @@ Struct("TodoItemRow") { Init("Task") { ParameterExp(unlabeled: Closure( capture: { - ParameterExp(unlabeled: VariableExp("self").reference("weak")) + ParameterExp(unlabeled: VariableExp("self").reference(.weak)) }, body: { VariableExp("self").optional().call("onToggle") { @@ -62,4 +62,4 @@ Struct("TodoItemRow") { } } .inherits("View") -.access("public") +.access(.public) diff --git a/Examples/Completed/enum_generator/EnumGeneratorExample.swift b/Examples/Demos/enum_generator/EnumGeneratorExample.swift similarity index 100% rename from Examples/Completed/enum_generator/EnumGeneratorExample.swift rename to Examples/Demos/enum_generator/EnumGeneratorExample.swift diff --git a/Examples/Completed/enum_generator/INTEGRATION_GUIDE.md b/Examples/Demos/enum_generator/INTEGRATION_GUIDE.md similarity index 98% rename from Examples/Completed/enum_generator/INTEGRATION_GUIDE.md rename to Examples/Demos/enum_generator/INTEGRATION_GUIDE.md index a355d1ed..0302a80a 100644 --- a/Examples/Completed/enum_generator/INTEGRATION_GUIDE.md +++ b/Examples/Demos/enum_generator/INTEGRATION_GUIDE.md @@ -6,7 +6,7 @@ This guide demonstrates the real-world impact of using SyntaxKit for dynamic enu ```bash # See the value proposition in action -cd Examples/Completed/enum_generator +cd Examples/Demos/enum_generator swift demo.swift ``` diff --git a/Examples/Completed/enum_generator/Package.resolved b/Examples/Demos/enum_generator/Package.resolved similarity index 100% rename from Examples/Completed/enum_generator/Package.resolved rename to Examples/Demos/enum_generator/Package.resolved diff --git a/Examples/Completed/enum_generator/Package.swift b/Examples/Demos/enum_generator/Package.swift similarity index 100% rename from Examples/Completed/enum_generator/Package.swift rename to Examples/Demos/enum_generator/Package.swift diff --git a/Examples/Completed/enum_generator/README.md b/Examples/Demos/enum_generator/README.md similarity index 100% rename from Examples/Completed/enum_generator/README.md rename to Examples/Demos/enum_generator/README.md diff --git a/Examples/Completed/enum_generator/after/Generated.swift b/Examples/Demos/enum_generator/after/Generated.swift similarity index 100% rename from Examples/Completed/enum_generator/after/Generated.swift rename to Examples/Demos/enum_generator/after/Generated.swift diff --git a/Examples/Completed/enum_generator/after/enum_generator.swift b/Examples/Demos/enum_generator/after/enum_generator.swift similarity index 100% rename from Examples/Completed/enum_generator/after/enum_generator.swift rename to Examples/Demos/enum_generator/after/enum_generator.swift diff --git a/Examples/Completed/enum_generator/api-config.json b/Examples/Demos/enum_generator/api-config.json similarity index 100% rename from Examples/Completed/enum_generator/api-config.json rename to Examples/Demos/enum_generator/api-config.json diff --git a/Examples/Completed/enum_generator/before/APIEndpoint.swift b/Examples/Demos/enum_generator/before/APIEndpoint.swift similarity index 100% rename from Examples/Completed/enum_generator/before/APIEndpoint.swift rename to Examples/Demos/enum_generator/before/APIEndpoint.swift diff --git a/Examples/Completed/enum_generator/before/HTTPStatus.swift b/Examples/Demos/enum_generator/before/HTTPStatus.swift similarity index 100% rename from Examples/Completed/enum_generator/before/HTTPStatus.swift rename to Examples/Demos/enum_generator/before/HTTPStatus.swift diff --git a/Examples/Completed/enum_generator/before/NetworkError.swift b/Examples/Demos/enum_generator/before/NetworkError.swift similarity index 100% rename from Examples/Completed/enum_generator/before/NetworkError.swift rename to Examples/Demos/enum_generator/before/NetworkError.swift diff --git a/Examples/Completed/enum_generator/code.swift b/Examples/Demos/enum_generator/code.swift similarity index 100% rename from Examples/Completed/enum_generator/code.swift rename to Examples/Demos/enum_generator/code.swift diff --git a/Examples/Completed/enum_generator/demo.swift b/Examples/Demos/enum_generator/demo.swift similarity index 100% rename from Examples/Completed/enum_generator/demo.swift rename to Examples/Demos/enum_generator/demo.swift diff --git a/Examples/Completed/enum_generator/dsl.swift b/Examples/Demos/enum_generator/dsl.swift similarity index 100% rename from Examples/Completed/enum_generator/dsl.swift rename to Examples/Demos/enum_generator/dsl.swift diff --git a/Examples/Completed/enum_generator/generate.swift b/Examples/Demos/enum_generator/generate.swift similarity index 94% rename from Examples/Completed/enum_generator/generate.swift rename to Examples/Demos/enum_generator/generate.swift index d403b4e3..ea13b075 100644 --- a/Examples/Completed/enum_generator/generate.swift +++ b/Examples/Demos/enum_generator/generate.swift @@ -8,7 +8,7 @@ let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/swift") process.arguments = [ "run", "--package-path", "../../../", "swift", - "Examples/Completed/enum_generator/dsl.swift" + "Examples/Demos/enum_generator/dsl.swift" ] process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) diff --git a/Examples/Completed/enum_generator/integration_demo.swift b/Examples/Demos/enum_generator/integration_demo.swift similarity index 100% rename from Examples/Completed/enum_generator/integration_demo.swift rename to Examples/Demos/enum_generator/integration_demo.swift diff --git a/Examples/Completed/enum_generator/main.swift b/Examples/Demos/enum_generator/main.swift similarity index 100% rename from Examples/Completed/enum_generator/main.swift rename to Examples/Demos/enum_generator/main.swift diff --git a/Package.resolved b/Package.resolved index 24cfefae..99337efe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "482d43aff5bb5c075d237e0ea17c12ee2c043b2642e459260752aa1848a20593", + "originHash" : "899b1fb6639e07a99f4d6f6c1e22b7c90948b2df293879cf18e8b0f87500bf16", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", @@ -19,6 +28,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", + "version" : "0.4.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -27,6 +45,15 @@ "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", "version" : "601.0.1" } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index baf98d69..4e02cd60 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,22 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. +// `import Foundation` in a package manifest is intentional and supported: it's +// here for `ProcessInfo.processInfo.environment` below (the SYNTAXKIT_DYNAMIC_LIB +// build switch). Don't "tidy" it away. +import Foundation import PackageDescription +// MARK: - Library Linkage + +// The SyntaxKit library product is normally built with automatic (static) +// linkage. The self-contained skit release bundle (Scripts/build-skit-release.sh) +// needs a dynamic libSyntaxKit.dylib instead, so it sets SYNTAXKIT_DYNAMIC_LIB=1 +// rather than patching this manifest in place. +// swiftlint:disable:next explicit_top_level_acl explicit_acl +let syntaxKitLibraryType: Product.Library.LibraryType? = + ProcessInfo.processInfo.environment["SYNTAXKIT_DYNAMIC_LIB"] == "1" ? .dynamic : nil + // MARK: - Swift Settings Configuration // swiftlint:disable:next explicit_top_level_acl explicit_acl @@ -59,21 +73,9 @@ let swiftSettings: [SwiftSetting] = [ // Warn unsafe reflection .enableExperimentalFeature("WarnUnsafeReflection"), - // Enhanced compiler checking - // .unsafeFlags([ - // // Enable concurrency warnings - // "-warn-concurrency", - // // Enable actor data race checks - // "-enable-actor-data-race-checks", - // // Complete strict concurrency checking - // "-strict-concurrency=complete", - // // Enable testing support - // "-enable-testing", - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) + // NOTE: strict-concurrency / actor-data-race / long-body warnings are not + // enabled yet — they're too noisy against the current SwiftSyntax-heavy code. + // Revisit once the codebase is closer to clean under `-strict-concurrency`. ] // swiftlint:disable:next explicit_top_level_acl explicit_acl @@ -89,6 +91,7 @@ let package = Package( products: [ .library( name: "SyntaxKit", + type: syntaxKitLibraryType, targets: ["SyntaxKit"] ), .executable( @@ -98,7 +101,10 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.1"), - .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0") + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0") ], targets: [ .target( @@ -127,6 +133,7 @@ let package = Package( .product(name: "SwiftOperators", package: "swift-syntax"), .product(name: "SwiftParser", package: "swift-syntax") ], + exclude: ["README.md"], swiftSettings: swiftSettings ), .target( @@ -140,12 +147,35 @@ let package = Package( ), .executableTarget( name: "skit", - dependencies: ["SyntaxParser"], + dependencies: [ + "SyntaxKit", + "SyntaxParser", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product( + name: "Subprocess", + package: "swift-subprocess", + condition: .when(platforms: [.macOS, .linux, .windows]) + ), + .product( + name: "SystemPackage", + package: "swift-system", + condition: .when(platforms: [.linux, .windows]) + ) + ], swiftSettings: swiftSettings ), .testTarget( name: "SyntaxKitTests", - dependencies: ["SyntaxKit"], + dependencies: [ + "SyntaxKit", + .product( + name: "Subprocess", + package: "swift-subprocess", + condition: .when(platforms: [.macOS, .linux, .windows]) + ) + ], swiftSettings: swiftSettings ), .testTarget( diff --git a/Scripts/build-skit.sh b/Scripts/build-skit.sh new file mode 100755 index 00000000..f041e35b --- /dev/null +++ b/Scripts/build-skit.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Build a self-contained skit bundle. +# +# ./Scripts/build-skit.sh # release bundle → .build/skit-release/ +# ./Scripts/build-skit.sh --debug # debug bundle → .build/skit-debug/ +# +# Output: .build/skit-${CONFIG}/ +# skit ← the CLI binary +# lib/ +# libSyntaxKit.dylib ← release: stripped; debug: as-built +# *.swiftmodule ← SyntaxKit + transitively re-exported modules +# _SwiftSyntaxCShims-include/ ← C-shims headers (module map + .h files) +# swift-version.txt ← toolchain stamp for startup check +# +# Once produced, the bundle is portable: copy the whole directory anywhere, +# and `./skit-${CONFIG}/skit ` Just Works — no flags, no env vars, no +# SyntaxKit checkout required. The release variant runs a 5–15 minute +# optimized SwiftSyntax compile; the debug variant skips that and finishes +# in ~10 seconds (use it for local iteration). + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS-only. Linux uses a parallel flow (build, then strip the" >&2 + echo "Mach-O install_name step)." >&2 + exit 1 +fi + +CONFIG="release" +SWIFT_CONFIG_FLAGS=() +STRIP_DYLIB=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) + CONFIG="debug" + SWIFT_CONFIG_FLAGS=() + STRIP_DYLIB=0 + ;; + --release) + CONFIG="release" + SWIFT_CONFIG_FLAGS=(-c release) + STRIP_DYLIB=1 + ;; + -h | --help) + sed -n '2,18p' "$0" + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +# Default config is release; SWIFT_CONFIG_FLAGS was left empty so set it here. +if [[ "$CONFIG" == "release" && ${#SWIFT_CONFIG_FLAGS[@]} -eq 0 ]]; then + SWIFT_CONFIG_FLAGS=(-c release) +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/.build/skit-${CONFIG}" + +cd "$REPO_ROOT" + +# Build the SyntaxKit library product as a dynamic libSyntaxKit.dylib. Package.swift +# reads SYNTAXKIT_DYNAMIC_LIB and flips the library product to type: .dynamic, so +# we never mutate the canonical manifest. +echo "==> Building with SYNTAXKIT_DYNAMIC_LIB=1 (dynamic libSyntaxKit)" +export SYNTAXKIT_DYNAMIC_LIB=1 + +echo "==> swift build ${SWIFT_CONFIG_FLAGS[*]} --product skit" +swift build "${SWIFT_CONFIG_FLAGS[@]}" --product skit + +# `skit` doesn't depend on SyntaxKit (it spawns swift on user input that +# imports SyntaxKit at runtime). Build the library product explicitly so the +# .dynamic flip above produces libSyntaxKit.dylib + swiftmodule. +echo "==> swift build ${SWIFT_CONFIG_FLAGS[*]} --product SyntaxKit" +swift build "${SWIFT_CONFIG_FLAGS[@]}" --product SyntaxKit + +# Ask SPM for the canonical bin dir rather than globbing `.build//`, +# which picks an arbitrary match (`head -1`) when several host triples exist. +BUILD_DIR="$(swift build "${SWIFT_CONFIG_FLAGS[@]}" --show-bin-path 2>/dev/null)" +if [[ -z "$BUILD_DIR" || ! -d "$BUILD_DIR" ]]; then + echo "Could not locate ${CONFIG} build dir via 'swift build --show-bin-path'" >&2 + exit 1 +fi + +echo "==> Staging $OUTPUT_DIR" +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR/lib" + +cp "$BUILD_DIR/skit" "$OUTPUT_DIR/skit" +cp "$BUILD_DIR/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/" +if [[ "$STRIP_DYLIB" == "1" ]]; then + strip -x "$OUTPUT_DIR/lib/libSyntaxKit.dylib" +fi +cp -r "$BUILD_DIR/Modules/." "$OUTPUT_DIR/lib/" +cp -r "$REPO_ROOT/.build/checkouts/swift-syntax/Sources/_SwiftSyntaxCShims/include" \ + "$OUTPUT_DIR/lib/_SwiftSyntaxCShims-include" + +# Ensure the dylib's install_name uses @rpath so the bundle is portable. +install_name_tool -id "@rpath/libSyntaxKit.dylib" "$OUTPUT_DIR/lib/libSyntaxKit.dylib" 2>/dev/null || true + +# Stamp the bundle with the build toolchain. `skit` compares this against the +# user's `swift --version` at startup and refuses to spawn `swift` if the +# swiftmodule wouldn't load. Issue #157 will replace the refusal with an +# auto-rebuild fallback. +swift --version > "$OUTPUT_DIR/lib/swift-version.txt" + +BINARY_SIZE=$(ls -lh "$OUTPUT_DIR/skit" | awk '{print $5}') +DYLIB_SIZE=$(ls -lh "$OUTPUT_DIR/lib/libSyntaxKit.dylib" | awk '{print $5}') +TOTAL_SIZE=$(du -sh "$OUTPUT_DIR" | awk '{print $1}') + +echo +echo "==> ${CONFIG} bundle ready:" +echo " Binary: $BINARY_SIZE" +echo " Dylib: $DYLIB_SIZE" +echo " Total: $TOTAL_SIZE" +echo +echo "==> Try it:" +echo " $OUTPUT_DIR/skit run " diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 5b4560fd..5769c601 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -1,16 +1,12 @@ #!/bin/bash -# Remove set -e to prevent immediate exit on errors +# Remove set -e to allow script to continue running # set -e # Exit on any error ERRORS=0 run_command() { - if [ "$LINT_MODE" = "STRICT" ]; then - "$@" || ERRORS=$((ERRORS + 1)) - else - "$@" || ERRORS=$((ERRORS + 1)) - fi + "$@" || ERRORS=$((ERRORS + 1)) } if [ "$LINT_MODE" = "INSTALL" ]; then @@ -21,59 +17,59 @@ echo "LintMode: $LINT_MODE" # More portable way to get script directory if [ -z "$SRCROOT" ]; then - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - PACKAGE_DIR="${SCRIPT_DIR}/.." + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." else - PACKAGE_DIR="${SRCROOT}" + PACKAGE_DIR="${SRCROOT}" +fi + +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi if [ "$LINT_MODE" = "NONE" ]; then exit elif [ "$LINT_MODE" = "STRICT" ]; then - SWIFTFORMAT_OPTIONS="--strict --configuration .swift-format" + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTFORMAT_LINT_STRICT="--strict" SWIFTLINT_OPTIONS="--strict" STRINGSLINT_OPTIONS="--config .strict.stringslint.yml" else SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTFORMAT_LINT_STRICT="" SWIFTLINT_OPTIONS="" STRINGSLINT_OPTIONS="--config .stringslint.yml" fi -pushd "$PACKAGE_DIR" -if [ -z "$CI" ]; then - mise install -fi -if command -v mise &> /dev/null; then - eval "$(mise env)" -fi +pushd $PACKAGE_DIR if [ -z "$CI" ]; then run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command swiftlint --fix + run_command swiftlint --fix Sources Tests fi if [ -z "$FORMAT_ONLY" ]; then - run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command swiftlint lint $SWIFTLINT_OPTIONS + run_command swift-format lint $SWIFTFORMAT_LINT_STRICT $SWIFTFORMAT_OPTIONS --recursive --parallel Sources Tests + run_command swiftlint lint $SWIFTLINT_OPTIONS Sources Tests + # Check for compilation errors + run_command swift build --build-tests fi -$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "SyntaxKit" - -run_command swiftlint lint $SWIFTLINT_OPTIONS -run_command swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests +$PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "SyntaxKit" +$PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Tests" -c "Leo Dion" -o "BrightDigit" -p "SyntaxKit" if [ -z "$CI" ]; then - run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check + run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check fi - popd -# Return error count at the end instead of exiting immediately +# Exit with error code if any errors occurred if [ $ERRORS -gt 0 ]; then - echo "Lint script completed with $ERRORS error(s)" - exit $ERRORS + echo "Linting completed with $ERRORS error(s)" + exit 1 else - echo "Lint script completed successfully" - exit 0 + echo "Linting completed successfully" + exit 0 fi diff --git a/Sources/DocumentationHarness/CodeBlockType.swift b/Sources/DocumentationHarness/CodeBlockType.swift index 08435090..ab432807 100644 --- a/Sources/DocumentationHarness/CodeBlockType.swift +++ b/Sources/DocumentationHarness/CodeBlockType.swift @@ -31,6 +31,7 @@ import Foundation internal enum CodeBlockType: Sendable { case example + // periphery:ignore - intentionally unavailable; retained for source compatibility @available( *, unavailable, message: "Parsing Package.swift manifests as documentation code blocks is unsupported." diff --git a/Sources/DocumentationHarness/CodeBlockValidationParameters.swift b/Sources/DocumentationHarness/CodeBlockValidationParameters.swift index e13c0619..2950df3f 100644 --- a/Sources/DocumentationHarness/CodeBlockValidationParameters.swift +++ b/Sources/DocumentationHarness/CodeBlockValidationParameters.swift @@ -33,6 +33,7 @@ import Foundation internal struct CodeBlockValidationParameters: ValidationParameters, Sendable { internal let codeBlock: CodeBlock internal let fileURL: URL + // periphery:ignore - carried for downstream diagnostic context; intentionally unread here internal let blockIndex: Int // MARK: - ValidationParameters conformance diff --git a/Sources/DocumentationHarness/DocumentationValidator.swift b/Sources/DocumentationHarness/DocumentationValidator.swift index cdc1a627..6ece6b67 100644 --- a/Sources/DocumentationHarness/DocumentationValidator.swift +++ b/Sources/DocumentationHarness/DocumentationValidator.swift @@ -41,7 +41,6 @@ package struct DocumentationValidator: Validator { /// Creates a new documentation test harness /// - Parameters: /// - codeValidator: Validator for Swift code syntax (defaults to CodeSyntaxValidator) - /// - fileSearcher: File system searcher (defaults to FileManager.default) /// - codeBlocksFrom: Function to extract code blocks from content package init( codeValidator: any SyntaxValidator = CodeSyntaxValidator(), diff --git a/Sources/DocumentationHarness/PackageValidator.swift b/Sources/DocumentationHarness/PackageValidator.swift index b10107cd..1704b312 100644 --- a/Sources/DocumentationHarness/PackageValidator.swift +++ b/Sources/DocumentationHarness/PackageValidator.swift @@ -30,6 +30,7 @@ import Foundation #if canImport(Foundation) && (os(macOS) || os(Linux)) + // periphery:ignore - intentionally unavailable; preserved for future enablement @available(*, unavailable) private enum PackageValidator { /// Validates a Package.swift manifest diff --git a/Sources/DocumentationHarness/ProcessError.swift b/Sources/DocumentationHarness/ProcessError.swift new file mode 100644 index 00000000..2fc65869 --- /dev/null +++ b/Sources/DocumentationHarness/ProcessError.swift @@ -0,0 +1,38 @@ +// +// ProcessError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Errors that can occur during process execution +package enum ProcessError: Error, Sendable { + /// Package.swift validation failed + case packageValidationFailed + /// Package validation setup failed + case setupError(any Error) +} diff --git a/Sources/DocumentationHarness/TestType.swift b/Sources/DocumentationHarness/TestType.swift index 40dcfe0b..1671f5f5 100644 --- a/Sources/DocumentationHarness/TestType.swift +++ b/Sources/DocumentationHarness/TestType.swift @@ -33,6 +33,7 @@ import Foundation package enum TestType { /// Code was parsed for syntax validation only case parsing + // periphery:ignore - part of the package's public API surface; consumers may switch on it /// Code was executed (compiled and run) case execution /// Code validation was skipped diff --git a/Sources/DocumentationHarness/ValidationError.swift b/Sources/DocumentationHarness/ValidationError.swift index d16a113f..c3066311 100644 --- a/Sources/DocumentationHarness/ValidationError.swift +++ b/Sources/DocumentationHarness/ValidationError.swift @@ -29,14 +29,6 @@ import Foundation -/// Errors that can occur during process execution -package enum ProcessError: Error, Sendable { - /// Package.swift validation failed - case packageValidationFailed - /// Package validation setup failed - case setupError(any Error) -} - /// Errors that can occur during Swift code validation package enum ValidationError: Error, Sendable { /// Syntax parsing detected errors in the code diff --git a/Sources/DocumentationHarness/Validator.swift b/Sources/DocumentationHarness/Validator.swift index b01a2e41..07cc0347 100644 --- a/Sources/DocumentationHarness/Validator.swift +++ b/Sources/DocumentationHarness/Validator.swift @@ -33,24 +33,24 @@ package protocol Validator { func validateFile(at fileURL: URL) throws -> [ValidationResult] } -private let privateDefaultPathExtensions = ["md"] -extension Validator { +extension [String] { /// Default file extensions for documentation files - package static var defaultPathExtensions: [String] { - privateDefaultPathExtensions - } + fileprivate static let defaultPathExtensions: [String] = ["md"] +} +extension Validator { /// Validates all Swift code examples found in documentation files /// - Parameters: /// - relativePaths: Array of relative paths to search for documentation /// - projectRoot: Root URL of the project /// - pathExtensions: File extensions to search for (defaults to ["md"]) + /// - fileSearcher: File system searcher (defaults to ``FileManager``.``default``) /// - Returns: Array of validation results for all code blocks found /// - Throws: FileSearchError if file operations fail package func validate( relativePaths: [String], atProjectRoot projectRoot: URL, - withPathExtensions pathExtensions: [String] = Self.defaultPathExtensions, + withPathExtensions pathExtensions: [String] = .defaultPathExtensions, using fileSearcher: any FileSearcher = FileManager.default ) throws -> [ValidationResult] { let documentationFiles = try relativePaths.flatMap { docPath in diff --git a/Sources/SyntaxKit/CodeBlocks/CodeBlock+ExprSyntax.swift b/Sources/SyntaxKit/CodeBlocks/CodeBlock+ExprSyntax.swift index e07fc1ca..2cd32f0b 100644 --- a/Sources/SyntaxKit/CodeBlocks/CodeBlock+ExprSyntax.swift +++ b/Sources/SyntaxKit/CodeBlocks/CodeBlock+ExprSyntax.swift @@ -46,11 +46,9 @@ extension CodeBlock { return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(token.text))) } - // Fallback for unsupported syntax types - create a default expression - // This prevents crashes while still allowing code generation to continue - #warning( - "TODO: Review fallback for unsupported syntax types - consider if this should be an error instead" - ) + // TODO: Review fallback for unsupported syntax types - consider if this should be an error instead. + // Fallback for unsupported syntax types - create a default expression so code + // generation can continue rather than crashing. return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))) } } diff --git a/Sources/SyntaxKit/CodeBlocks/CodeBlock+Generate.swift b/Sources/SyntaxKit/CodeBlocks/CodeBlock+Generate.swift index 914fdf6b..80fb6618 100644 --- a/Sources/SyntaxKit/CodeBlocks/CodeBlock+Generate.swift +++ b/Sources/SyntaxKit/CodeBlocks/CodeBlock+Generate.swift @@ -28,7 +28,7 @@ // import Foundation -public import SwiftSyntax +import SwiftSyntax extension CodeBlock { /// Generates the Swift code for the ``CodeBlock``. @@ -45,11 +45,9 @@ extension CodeBlock { if let convertedItem = CodeBlockItemSyntax.Item.create(from: self.syntax) { item = convertedItem } else { - // Fallback for unsupported syntax types - create an empty code block - // This prevents crashes while still allowing code generation to continue - #warning( - "TODO: Review fallback for unsupported syntax types - consider if this should be an error instead" - ) + // TODO: Review fallback for unsupported syntax types - consider if this should be an error instead. + // Fallback for unsupported syntax types - create an empty code block so code + // generation can continue rather than crashing. let emptyExpr = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))) item = .expr(emptyExpr) } diff --git a/Sources/SyntaxKit/CodeBlocks/CodeBlockItemSyntax.Item.swift b/Sources/SyntaxKit/CodeBlocks/CodeBlockItemSyntax.Item.swift index f19bb6f8..996def5a 100644 --- a/Sources/SyntaxKit/CodeBlocks/CodeBlockItemSyntax.Item.swift +++ b/Sources/SyntaxKit/CodeBlocks/CodeBlockItemSyntax.Item.swift @@ -45,11 +45,9 @@ extension CodeBlockItemSyntax.Item { let expr = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(token.text))) return .expr(expr) } else if let switchCase = syntax.as(SwitchCaseSyntax.self) { - // Wrap SwitchCaseSyntax in a SwitchExprSyntax and treat it as an expression - // This is a fallback for when SwitchCase is used standalone - #warning( - "TODO: Review fallback for SwitchCase used standalone - consider if this should be an error instead" - ) + // TODO: Review fallback for SwitchCase used standalone - consider if this should be an error instead. + // Wrap SwitchCaseSyntax in a SwitchExprSyntax and treat it as an expression so + // standalone SwitchCase usage continues to work. let switchExpr = SwitchExprSyntax( switchKeyword: .keyword(.switch, trailingTrivia: .space), subject: ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("_"))), diff --git a/Sources/SyntaxKit/CodeBlocks/CommentedCodeBlock.swift b/Sources/SyntaxKit/CodeBlocks/CommentedCodeBlock.swift index 1744372f..f616e3de 100644 --- a/Sources/SyntaxKit/CodeBlocks/CommentedCodeBlock.swift +++ b/Sources/SyntaxKit/CodeBlocks/CommentedCodeBlock.swift @@ -59,8 +59,8 @@ internal struct CommentedCodeBlock: CodeBlock { let commentTrivia = Trivia(pieces: lines.flatMap { [$0.triviaPiece, TriviaPiece.newlines(1)] }) guard let firstToken = base.syntax.firstToken(viewMode: .sourceAccurate) else { - // Fallback – no tokens? return original syntax - #warning("TODO: Review fallback for no tokens - consider if this should be an error instead") + // TODO: Review fallback for no tokens - consider if this should be an error instead. + // Fallback – no tokens? return original syntax. return base.syntax } diff --git a/Sources/SyntaxKit/CodeBlocks/EmptyCodeBlock.swift b/Sources/SyntaxKit/CodeBlocks/EmptyCodeBlock.swift index 4ebdf4d0..bbaba89c 100644 --- a/Sources/SyntaxKit/CodeBlocks/EmptyCodeBlock.swift +++ b/Sources/SyntaxKit/CodeBlocks/EmptyCodeBlock.swift @@ -28,7 +28,7 @@ // import Foundation -public import SwiftSyntax +import SwiftSyntax /// An empty code block that generates no syntax. internal struct EmptyCodeBlock: CodeBlock, Sendable, Equatable { diff --git a/Sources/SyntaxKit/Collections/CodeBlock+DictionaryValue.swift b/Sources/SyntaxKit/Collections/CodeBlock+DictionaryValue.swift index 8a38ca05..8f1575f1 100644 --- a/Sources/SyntaxKit/Collections/CodeBlock+DictionaryValue.swift +++ b/Sources/SyntaxKit/Collections/CodeBlock+DictionaryValue.swift @@ -45,11 +45,9 @@ extension CodeBlock where Self: DictionaryValue { return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(token.text))) } - // Fallback for unsupported syntax types - create a default expression - // This prevents crashes while still allowing dictionary operations to continue - #warning( - "TODO: Review fallback for unsupported syntax types - consider if this should be an error instead" - ) + // TODO: Review fallback for unsupported syntax types - consider if this should be an error instead. + // Fallback for unsupported syntax types - create a default expression so dictionary + // operations can continue rather than crashing. return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))) } } diff --git a/Sources/SyntaxKit/Collections/Tuple.swift b/Sources/SyntaxKit/Collections/Tuple.swift index df42ff32..60013ea0 100644 --- a/Sources/SyntaxKit/Collections/Tuple.swift +++ b/Sources/SyntaxKit/Collections/Tuple.swift @@ -37,7 +37,7 @@ public struct Tuple: CodeBlock { /// The SwiftSyntax representation of this tuple expression. public var syntax: any SyntaxProtocol { - let list = TupleExprElementListSyntax( + let list = LabeledExprListSyntax( elements.enumerated().map { index, block in let elementExpr: ExprSyntax if isAsync { @@ -53,7 +53,7 @@ public struct Tuple: CodeBlock { elementExpr = block.expr } - return TupleExprElementSyntax( + return LabeledExprSyntax( label: nil, colon: nil, expression: elementExpr, @@ -90,12 +90,14 @@ public struct Tuple: CodeBlock { /// Creates a tuple pattern for switch cases. /// - Parameter elements: Array of pattern elements, where `nil` represents a wildcard pattern. + /// - Returns: A pattern that can match the supplied elements in a `switch` case. public static func pattern(_ elements: [(any PatternConvertible)?]) -> any PatternConvertible { TuplePattern(elements: elements) } /// Creates a tuple pattern that can be used as a CodeBlock. /// - Parameter elements: Array of pattern elements, where `nil` represents a wildcard pattern. + /// - Returns: A pattern wrapped as a ``PatternCodeBlock`` for use inside builders. public static func patternCodeBlock(_ elements: [(any PatternConvertible)?]) -> any PatternCodeBlock { diff --git a/Sources/SyntaxKit/Collections/TupleAssignment.swift b/Sources/SyntaxKit/Collections/TupleAssignment.swift index 42b93649..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 @@ -83,11 +83,9 @@ internal struct TupleAssignment: CodeBlock { private func generateAsyncSetSyntax() -> any SyntaxProtocol { // Generate a single async let tuple destructuring assignment guard let tuple = value as? Tuple, elements.count == tuple.elements.count else { - // Fallback to regular syntax if conditions aren't met for asyncSet - // This provides a more robust API instead of crashing - #warning( - "TODO: Review fallback for asyncSet conditions - consider if this should be an error instead" - ) + // TODO: Review fallback for asyncSet conditions - consider if this should be an error instead. + // Fallback to regular syntax if conditions aren't met for asyncSet so callers get + // a usable result instead of crashing. return generateRegularSyntax() } diff --git a/Sources/SyntaxKit/ControlFlow/For.swift b/Sources/SyntaxKit/ControlFlow/For.swift index 2b790170..f4111a9e 100644 --- a/Sources/SyntaxKit/ControlFlow/For.swift +++ b/Sources/SyntaxKit/ControlFlow/For.swift @@ -56,7 +56,7 @@ public struct For: CodeBlock, Sendable { ) whereClauseSyntax = WhereClauseSyntax( whereKeyword: .keyword(.where, leadingTrivia: .space, trailingTrivia: .space), - guardResult: whereExpr + condition: whereExpr ) } @@ -80,7 +80,7 @@ public struct For: CodeBlock, Sendable { ) return StmtSyntax( - ForInStmtSyntax( + ForStmtSyntax( forKeyword: .keyword(.for, trailingTrivia: .space), tryKeyword: nil, awaitKeyword: nil, diff --git a/Sources/SyntaxKit/ControlFlow/Guard.swift b/Sources/SyntaxKit/ControlFlow/Guard.swift index 47e12576..84ff5b9c 100644 --- a/Sources/SyntaxKit/ControlFlow/Guard.swift +++ b/Sources/SyntaxKit/ControlFlow/Guard.swift @@ -34,6 +34,7 @@ public struct Guard: CodeBlock, Sendable { private let conditions: [any CodeBlock] private let elseBody: [any CodeBlock] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { // MARK: Build conditions list (mirror implementation from `If`) let condList = ConditionElementListSyntax( @@ -135,8 +136,7 @@ public struct Guard: CodeBlock, Sendable { } /// Creates a `guard` statement without a condition (uses true as default). - /// - Parameters: - /// - elseBody: A ``CodeBlockBuilder`` that provides the body when the condition is false. + /// - Parameter elseBody: A ``CodeBlockBuilder`` that provides the body when the condition is false. public init( @CodeBlockBuilderResult else elseBody: () throws -> [any CodeBlock] ) rethrows { diff --git a/Sources/SyntaxKit/ControlFlow/If+ElseBody.swift b/Sources/SyntaxKit/ControlFlow/If+ElseBody.swift index a4b373bc..aec1affc 100644 --- a/Sources/SyntaxKit/ControlFlow/If+ElseBody.swift +++ b/Sources/SyntaxKit/ControlFlow/If+ElseBody.swift @@ -83,9 +83,8 @@ extension If { } else if let nestedIf = nested.as(IfExprSyntax.self) { return IfExprSyntax.ElseBody(nestedIf) } else { + // TODO: Review fallback to empty code block - consider if this should be an error instead. // Fallback to empty code block - #warning( - "TODO: Review fallback to empty code block - consider if this should be an error instead") return IfExprSyntax.ElseBody( CodeBlockSyntax( leftBrace: .leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), diff --git a/Sources/SyntaxKit/ControlFlow/Switch.swift b/Sources/SyntaxKit/ControlFlow/Switch.swift index b1327a6b..9b407a50 100644 --- a/Sources/SyntaxKit/ControlFlow/Switch.swift +++ b/Sources/SyntaxKit/ControlFlow/Switch.swift @@ -34,6 +34,7 @@ public struct Switch: CodeBlock, Sendable { private let expression: any CodeBlock private let cases: [any CodeBlock] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let expr = ExprSyntax( fromProtocol: expression.syntax.as(ExprSyntax.self) diff --git a/Sources/SyntaxKit/ControlFlow/While.swift b/Sources/SyntaxKit/ControlFlow/While.swift index 611d8253..5f323a9e 100644 --- a/Sources/SyntaxKit/ControlFlow/While.swift +++ b/Sources/SyntaxKit/ControlFlow/While.swift @@ -66,7 +66,7 @@ public struct While: CodeBlock, Sendable { switch kind { case .repeatWhile: return StmtSyntax( - RepeatWhileStmtSyntax( + RepeatStmtSyntax( repeatKeyword: .keyword(.repeat, trailingTrivia: .space), body: bodyBlock, whileKeyword: .keyword(.while, trailingTrivia: .space), @@ -107,8 +107,8 @@ public struct While: CodeBlock, Sendable { /// Creates a `while` loop statement with a builder closure for the condition. /// - Parameters: - /// - condition: A `CodeBlockBuilder` that produces exactly one condition expression. /// - kind: The kind of loop (default is `.while`). + /// - condition: A `CodeBlockBuilder` that produces exactly one condition expression. /// - then: A ``CodeBlockBuilder`` that provides the body of the loop. public init( kind: Kind = .while, @@ -122,8 +122,8 @@ public struct While: CodeBlock, Sendable { /// Creates a `while` loop. /// - Parameters: - /// - condition: A ``CodeBlockBuilder`` that provides the condition expression. /// - kind: The kind of loop (default is `.while`). + /// - condition: A ``CodeBlockBuilder`` that provides the condition expression. /// - then: A ``CodeBlockBuilder`` that provides the body of the loop. @available( *, deprecated, diff --git a/Sources/SyntaxKit/Declarations/Class+Modifiers.swift b/Sources/SyntaxKit/Declarations/Class+Modifiers.swift new file mode 100644 index 00000000..a747667f --- /dev/null +++ b/Sources/SyntaxKit/Declarations/Class+Modifiers.swift @@ -0,0 +1,76 @@ +// +// Class+Modifiers.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Class { + /// Sets the generic parameters for the class. + /// - Parameter generics: The list of generic parameter names. + /// - Returns: A copy of the class with the generic parameters set. + public func generic(_ generics: String...) -> Self { + var copy = self + copy.genericParameters = generics + return copy + } + + /// Sets the inheritance for the class. + /// - Parameter inheritance: The types to inherit from. + /// - Returns: A copy of the class with the inheritance set. + public func inherits(_ inheritance: String...) -> Self { + var copy = self + copy.inheritance = inheritance + return copy + } + + /// Marks the class declaration as `final`. + /// - Returns: A copy of the class marked as `final`. + public func final() -> Self { + var copy = self + copy.isFinal = true + return copy + } + + /// Sets the access modifier for the class declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the class with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Adds an attribute to the class declaration. + /// - Parameters: + /// - attribute: The attribute name (without the @ symbol). + /// - arguments: The arguments for the attribute, if any. + /// - Returns: A copy of the class with the attribute added. + public func attribute(_ attribute: String, arguments: [String] = []) -> Self { + var copy = self + copy.attributes.append(AttributeInfo(name: attribute, arguments: arguments)) + return copy + } +} diff --git a/Sources/SyntaxKit/Declarations/Class.swift b/Sources/SyntaxKit/Declarations/Class.swift index 2b6c03fc..5f26b6e6 100644 --- a/Sources/SyntaxKit/Declarations/Class.swift +++ b/Sources/SyntaxKit/Declarations/Class.swift @@ -31,12 +31,13 @@ public import SwiftSyntax /// A Swift `class` declaration. public struct Class: CodeBlock, Sendable { - private let name: String - private let members: [any CodeBlock] - private var inheritance: [String] = [] - private var genericParameters: [String] = [] - private var isFinal: Bool = false - private var attributes: [AttributeInfo] = [] + internal let name: String + internal let members: [any CodeBlock] + internal var inheritance: [String] = [] + internal var genericParameters: [String] = [] + internal var isFinal: Bool = false + internal var attributes: [AttributeInfo] = [] + internal var accessModifier: AccessModifier? /// The SwiftSyntax representation of this class declaration. public var syntax: any SyntaxProtocol { @@ -107,11 +108,16 @@ public struct Class: CodeBlock, Sendable { // Modifiers var modifiers: DeclModifierListSyntax = [] - if isFinal { + if let access = accessModifier { modifiers = DeclModifierListSyntax([ - DeclModifierSyntax(name: .keyword(.final, trailingTrivia: .space)) + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) ]) } + if isFinal { + modifiers = DeclModifierListSyntax( + modifiers + [DeclModifierSyntax(name: .keyword(.final, trailingTrivia: .space))] + ) + } return ClassDeclSyntax( attributes: attributeList, @@ -135,43 +141,6 @@ public struct Class: CodeBlock, Sendable { self.members = try content() } - /// Sets the generic parameters for the class. - /// - Parameter generics: The list of generic parameter names. - /// - Returns: A copy of the class with the generic parameters set. - public func generic(_ generics: String...) -> Self { - var copy = self - copy.genericParameters = generics - return copy - } - - /// Sets the inheritance for the class. - /// - Parameter inheritance: The types to inherit from. - /// - Returns: A copy of the class with the inheritance set. - public func inherits(_ inheritance: String...) -> Self { - var copy = self - copy.inheritance = inheritance - return copy - } - - /// Marks the class declaration as `final`. - /// - Returns: A copy of the class marked as `final`. - public func final() -> Self { - var copy = self - copy.isFinal = true - return copy - } - - /// Adds an attribute to the class declaration. - /// - Parameters: - /// - attribute: The attribute name (without the @ symbol). - /// - arguments: The arguments for the attribute, if any. - /// - Returns: A copy of the class with the attribute added. - public func attribute(_ attribute: String, arguments: [String] = []) -> Self { - var copy = self - copy.attributes.append(AttributeInfo(name: attribute, arguments: arguments)) - return copy - } - private func buildAttributeList(from attributes: [AttributeInfo]) -> AttributeListSyntax { if attributes.isEmpty { return AttributeListSyntax([]) @@ -189,13 +158,13 @@ public struct Class: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -213,6 +182,7 @@ public struct Class: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } diff --git a/Sources/SyntaxKit/Declarations/Enum.swift b/Sources/SyntaxKit/Declarations/Enum.swift index d915fd1e..9b58efc8 100644 --- a/Sources/SyntaxKit/Declarations/Enum.swift +++ b/Sources/SyntaxKit/Declarations/Enum.swift @@ -134,13 +134,13 @@ public struct Enum: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -158,6 +158,7 @@ public struct Enum: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Extension.swift b/Sources/SyntaxKit/Declarations/Extension.swift index 8c883289..5a8df562 100644 --- a/Sources/SyntaxKit/Declarations/Extension.swift +++ b/Sources/SyntaxKit/Declarations/Extension.swift @@ -36,6 +36,7 @@ public struct Extension: CodeBlock, Sendable { private var inheritance: [String] = [] private var attributes: [AttributeInfo] = [] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let extensionKeyword = TokenSyntax.keyword(.extension, trailingTrivia: .space) let identifier = TokenSyntax.identifier(extendedType, trailingTrivia: .space) @@ -130,13 +131,13 @@ public struct Extension: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -154,6 +155,7 @@ public struct Extension: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Import.swift b/Sources/SyntaxKit/Declarations/Import.swift index 2a94b716..089bb9c9 100644 --- a/Sources/SyntaxKit/Declarations/Import.swift +++ b/Sources/SyntaxKit/Declarations/Import.swift @@ -35,6 +35,7 @@ public struct Import: CodeBlock, Sendable { private var accessModifier: AccessModifier? private var attributes: [AttributeInfo] = [] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { // Build access modifier var modifiers: DeclModifierListSyntax = [] @@ -100,13 +101,13 @@ public struct Import: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -124,6 +125,7 @@ public struct Import: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .space) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Init.swift b/Sources/SyntaxKit/Declarations/Init.swift index 4adb18dc..74e04eee 100644 --- a/Sources/SyntaxKit/Declarations/Init.swift +++ b/Sources/SyntaxKit/Declarations/Init.swift @@ -39,6 +39,7 @@ public struct Init: CodeBlock, ExprCodeBlock, LiteralValue, CodeBlockable, Senda self } + /// The SwiftSyntax expression representation of this code block. public var exprSyntax: ExprSyntax { var args = parameters var trailingClosure: ClosureExprSyntax? @@ -99,16 +100,19 @@ public struct Init: CodeBlock, ExprCodeBlock, LiteralValue, CodeBlockable, Senda ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { exprSyntax } // MARK: - LiteralValue Conformance + /// The type name for this initializer. public var typeName: String { type } + /// The literal string representation. public var literalString: String { "\(type)()" } diff --git a/Sources/SyntaxKit/Declarations/InitializerDecl.swift b/Sources/SyntaxKit/Declarations/InitializerDecl.swift new file mode 100644 index 00000000..e84b4a8b --- /dev/null +++ b/Sources/SyntaxKit/Declarations/InitializerDecl.swift @@ -0,0 +1,145 @@ +// +// InitializerDecl.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import SwiftSyntax + +/// A Swift `init` declaration. +public struct InitializerDecl: CodeBlock, Sendable { + private let parameters: [Parameter] + private let body: [any CodeBlock] + private var accessModifier: AccessModifier? + private var isAsync: Bool = false + private var isThrowing: Bool = false + + /// The SwiftSyntax representation of this initializer declaration. + public var syntax: any SyntaxProtocol { + var modifiers: DeclModifierListSyntax = [] + if let access = accessModifier { + modifiers = DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) + ]) + } + + var effectSpecifiers: FunctionEffectSpecifiersSyntax? + if isAsync || isThrowing { + effectSpecifiers = FunctionEffectSpecifiersSyntax( + asyncSpecifier: isAsync ? .keyword(.async, leadingTrivia: .space) : nil, + throwsClause: isThrowing + ? ThrowsClauseSyntax(throwsSpecifier: .keyword(.throws, leadingTrivia: .space)) + : nil + ) + } + + let parameterList = FunctionParameterListSyntax( + parameters.enumerated().compactMap { index, param in + FunctionParameterSyntax.create( + from: param, + attributes: AttributeListSyntax([]), + isLast: index >= parameters.count - 1 + ) + } + ) + + let bodyBlock = CodeBlockSyntax( + leftBrace: .leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), + statements: CodeBlockItemListSyntax( + body.compactMap { item in + var codeBlockItem: CodeBlockItemSyntax? + if let decl = item.syntax.as(DeclSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .decl(decl)) + } else if let expr = item.syntax.as(ExprSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .expr(expr)) + } else if let stmt = item.syntax.as(StmtSyntax.self) { + codeBlockItem = CodeBlockItemSyntax(item: .stmt(stmt)) + } + return codeBlockItem?.with(\.trailingTrivia, .newline) + } + ), + rightBrace: .rightBraceToken(leadingTrivia: .newline) + ) + + return InitializerDeclSyntax( + modifiers: modifiers, + initKeyword: .keyword(.`init`), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + leftParen: .leftParenToken(), + parameters: parameterList, + rightParen: .rightParenToken() + ), + effectSpecifiers: effectSpecifiers + ), + body: bodyBlock + ) + } + + /// Creates an `init` declaration with no parameters. + /// - Parameter content: A ``CodeBlockBuilder`` that provides the body of the initializer. + public init(@CodeBlockBuilderResult _ content: () throws -> [any CodeBlock]) rethrows { + self.parameters = [] + self.body = try content() + } + + /// Creates an `init` declaration with parameters. + /// - Parameters: + /// - params: A ``ParameterBuilderResult`` that provides the initializer parameters. + /// - content: A ``CodeBlockBuilder`` that provides the body of the initializer. + public init( + @ParameterBuilderResult _ params: () -> [Parameter], + @CodeBlockBuilderResult _ content: () throws -> [any CodeBlock] + ) rethrows { + self.parameters = params() + self.body = try content() + } + + /// Sets the access modifier for the initializer declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the initializer with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Marks the initializer as `throws`. + /// - Returns: A copy of the initializer marked as `throws`. + public func throwing() -> Self { + var copy = self + copy.isThrowing = true + return copy + } + + /// Marks the initializer as `async`. + /// - Returns: A copy of the initializer marked as `async`. + public func async() -> Self { + var copy = self + copy.isAsync = true + return copy + } +} diff --git a/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift b/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift new file mode 100644 index 00000000..70fe247e --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf+Condition.swift @@ -0,0 +1,162 @@ +// +// PoundIf+Condition.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension PoundIf { + // swiftlint:disable identifier_name + + /// Canonical `#if` checks, mirroring Swift's conditional-compilation grammar. + public indirect enum Condition: Sendable { + /// `canImport()` + case canImport(String) + /// A bare compilation flag such as `DEBUG`. + case flag(String) + /// `os()` + case os(OperatingSystem) + /// `arch()` + case arch(Architecture) + /// `targetEnvironment()` + case targetEnvironment(TargetEnvironment) + /// `swift(>=5.9)` and friends. + case swift(VersionCheck) + /// `compiler(>=5.9)` and friends. + case compiler(VersionCheck) + /// `hasFeature()` + case hasFeature(String) + /// `hasAttribute()` + case hasAttribute(String) + /// ` && ` + case and(Condition, Condition) + /// ` || ` + case or(Condition, Condition) + /// `!` + case not(Condition) + } + + // swiftlint:enable identifier_name + + /// Operating-system identifiers used in `os(...)` checks. + public enum OperatingSystem: String, Sendable { + /// `os(iOS)` + case iOS + /// `os(macOS)` + case macOS + /// `os(tvOS)` + case tvOS + /// `os(watchOS)` + case watchOS + /// `os(visionOS)` + case visionOS + /// `os(Linux)` + case linux = "Linux" + /// `os(Windows)` + case windows = "Windows" + /// `os(FreeBSD)` + case freeBSD = "FreeBSD" + /// `os(Android)` + case android = "Android" + /// `os(WASI)` + case wasi = "WASI" + } + + /// CPU-architecture identifiers used in `arch(...)` checks. + public enum Architecture: String, Sendable { + /// `arch(arm64)` + case arm64 + /// `arch(x86_64)` + case x86 = "x86_64" + /// `arch(i386)` + case i386 + /// `arch(arm)` + case arm + /// `arch(wasm32)` + case wasm32 + } + + /// Target-environment identifiers used in `targetEnvironment(...)` checks. + public enum TargetEnvironment: String, Sendable { + /// `targetEnvironment(simulator)` + case simulator + /// `targetEnvironment(macCatalyst)` + case macCatalyst + } + + /// A `swift(>=5.9)` / `compiler(>=5.9)`-style version check. + public struct VersionCheck: Sendable { + /// The comparison operator used between the keyword and the version. + public enum Comparison: String, Sendable { + /// `>=` + case greaterThanOrEqual = ">=" + /// `>` + case greaterThan = ">" + /// `<=` + case lessThanOrEqual = "<=" + /// `<` + case lessThan = "<" + /// `==` + case equal = "==" + } + + /// The comparison operator that precedes the version. + public let comparison: Comparison + /// The major version component. + public let major: Int + /// The optional minor version component. + public let minor: Int? + /// The optional patch version component. + public let patch: Int? + + internal var versionString: String { + var result = String(major) + if let minor = minor { + result += ".\(minor)" + if let patch = patch { + result += ".\(patch)" + } + } + return result + } + + internal var rendered: String { + "\(comparison.rawValue)\(versionString)" + } + + /// Build a `>= major.minor[.patch]` check. + public static func atLeast(_ major: Int, _ minor: Int? = nil, _ patch: Int? = nil) + -> VersionCheck + { + VersionCheck(comparison: .greaterThanOrEqual, major: major, minor: minor, patch: patch) + } + + /// Build an `== major.minor[.patch]` check. + public static func exact(_ major: Int, _ minor: Int? = nil, _ patch: Int? = nil) -> VersionCheck + { + VersionCheck(comparison: .equal, major: major, minor: minor, patch: patch) + } + } +} diff --git a/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift b/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift new file mode 100644 index 00000000..2a711310 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf+Rendering.swift @@ -0,0 +1,119 @@ +// +// PoundIf+Rendering.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import SwiftParser +internal import SwiftSyntax + +extension PoundIf { + internal static func makeClause( + poundKeyword: TokenSyntax, + condition: ConditionForm?, + body: [any CodeBlock] + ) -> IfConfigClauseSyntax { + let items = CodeBlockItemListSyntax( + body.compactMap { block -> CodeBlockItemSyntax? in + CodeBlockItemSyntax.Item.create(from: block.syntax).map { + CodeBlockItemSyntax(item: $0, trailingTrivia: .newline) + } + } + ) + + let renderedCondition = condition.flatMap(Self.renderCondition)? + .with(\.trailingTrivia, .newline) + + return IfConfigClauseSyntax( + poundKeyword: poundKeyword, + condition: renderedCondition, + elements: .statements(items) + ) + } + + private static func renderCondition(_ form: ConditionForm) -> ExprSyntax? { + switch form { + case .helper(let condition): + return parseExpression(renderHelper(condition, atTopLevel: true)) + case .raw(let text): + return parseExpression(text) + case .codeBlock(let block): + if let expr = block.syntax.as(ExprSyntax.self) { + return expr + } + return parseExpression(block.generateCode()) + } + } + + private static func parseExpression(_ source: String) -> ExprSyntax? { + let file = Parser.parse(source: source) + for item in file.statements { + if let expr = item.item.as(ExprSyntax.self) { + return expr + } + } + return nil + } + + private static func renderHelper(_ condition: Condition, atTopLevel: Bool) -> String { + if let leaf = renderLeaf(condition) { + return leaf + } + return renderCombinator(condition, atTopLevel: atTopLevel) + } + + private static func renderLeaf(_ condition: Condition) -> String? { + switch condition { + case .canImport(let module): return "canImport(\(module))" + case .flag(let name): return name + case .os(let value): return "os(\(value.rawValue))" + case .arch(let value): return "arch(\(value.rawValue))" + case .targetEnvironment(let value): return "targetEnvironment(\(value.rawValue))" + case .swift(let check): return "swift(\(check.rendered))" + case .compiler(let check): return "compiler(\(check.rendered))" + case .hasFeature(let name): return "hasFeature(\(name))" + case .hasAttribute(let name): return "hasAttribute(\(name))" + case .and, .or, .not: return nil + } + } + + private static func renderCombinator(_ condition: Condition, atTopLevel: Bool) -> String { + switch condition { + case .and(let lhs, let rhs): + let inner = + "\(renderHelper(lhs, atTopLevel: false)) && \(renderHelper(rhs, atTopLevel: false))" + return atTopLevel ? inner : "(\(inner))" + case .or(let lhs, let rhs): + let inner = + "\(renderHelper(lhs, atTopLevel: false)) || \(renderHelper(rhs, atTopLevel: false))" + return atTopLevel ? inner : "(\(inner))" + case .not(let operand): + return "!\(renderHelper(operand, atTopLevel: false))" + default: + return "" + } + } +} diff --git a/Sources/SyntaxKit/Declarations/PoundIf.swift b/Sources/SyntaxKit/Declarations/PoundIf.swift new file mode 100644 index 00000000..a88d2f60 --- /dev/null +++ b/Sources/SyntaxKit/Declarations/PoundIf.swift @@ -0,0 +1,172 @@ +// +// PoundIf.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import SwiftSyntax + +/// A `#if … #elseif … #else … #endif` conditional compilation block. +public struct PoundIf: CodeBlock, Sendable { + /// One of the three accepted condition forms attached to a `#if` / `#elseif` clause. + internal enum ConditionForm: Sendable { + case helper(Condition) + case raw(String) + case codeBlock(any CodeBlock) + } + + /// A single `#if`, `#elseif`, or `#else` clause and the code blocks inside it. + internal struct Clause: Sendable { + internal let condition: ConditionForm? + internal let body: [any CodeBlock] + } + + private let head: Clause + private var elseifClauses: [Clause] = [] + private var elseBody: [any CodeBlock]? + + /// The SwiftSyntax representation of this conditional compilation block. + public var syntax: any SyntaxProtocol { + var clauses: [IfConfigClauseSyntax] = [ + Self.makeClause( + poundKeyword: .poundIfToken(trailingTrivia: .space), + condition: head.condition, + body: head.body + ) + ] + + for clause in elseifClauses { + clauses.append( + Self.makeClause( + poundKeyword: .poundElseifToken(leadingTrivia: .newline, trailingTrivia: .space), + condition: clause.condition, + body: clause.body + ) + ) + } + + if let elseBody = elseBody { + clauses.append( + Self.makeClause( + poundKeyword: .poundElseToken(leadingTrivia: .newline, trailingTrivia: .newline), + condition: nil, + body: elseBody + ) + ) + } + + return IfConfigDeclSyntax( + clauses: IfConfigClauseListSyntax(clauses), + poundEndif: .poundEndifToken(leadingTrivia: .newline) + ) + } + + /// `#if ` using the helper enum. + /// - Parameters: + /// - condition: The structured condition for the `#if` clause. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: Condition, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .helper(condition), body: content()) + } + + /// `#if ` escape hatch for any expression the helper enum cannot express. + /// - Parameters: + /// - condition: The raw condition text to emit after `#if`. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: String, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .raw(condition), body: content()) + } + + /// `#if ` escape hatch for callers that already have an expression value. + /// - Parameters: + /// - condition: The expression-shaped condition for the `#if` clause. + /// - content: The code blocks to emit when the condition is satisfied. + public init( + _ condition: any CodeBlock, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) { + self.head = Clause(condition: .codeBlock(condition), body: content()) + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The structured condition for the new `#elseif` clause. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: Condition, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .helper(condition), body: content())) + return copy + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The raw condition text to emit after `#elseif`. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: String, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .raw(condition), body: content())) + return copy + } + + /// Append a `#elseif ` clause. + /// - Parameters: + /// - condition: The expression-shaped condition for the new `#elseif` clause. + /// - content: The code blocks to emit when the condition is satisfied. + /// - Returns: A copy of `self` with the new clause appended. + public func elseif( + _ condition: any CodeBlock, + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseifClauses.append(Clause(condition: .codeBlock(condition), body: content())) + return copy + } + + /// Append a `#else` clause. + /// - Parameter content: The code blocks to emit when no earlier clause was satisfied. + /// - Returns: A copy of `self` with the `#else` clause set. + public func `else`( + @CodeBlockBuilderResult _ content: () -> [any CodeBlock] + ) -> Self { + var copy = self + copy.elseBody = content() + return copy + } +} diff --git a/Sources/SyntaxKit/Declarations/Protocol.swift b/Sources/SyntaxKit/Declarations/Protocol.swift index d93abf4c..0cda4a4c 100644 --- a/Sources/SyntaxKit/Declarations/Protocol.swift +++ b/Sources/SyntaxKit/Declarations/Protocol.swift @@ -36,6 +36,7 @@ public struct Protocol: CodeBlock, Sendable { private var inheritance: [String] = [] private var attributes: [AttributeInfo] = [] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let protocolKeyword = TokenSyntax.keyword(.protocol, trailingTrivia: .space) let identifier = TokenSyntax.identifier(name) @@ -135,13 +136,13 @@ public struct Protocol: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -159,6 +160,7 @@ public struct Protocol: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/Struct.swift b/Sources/SyntaxKit/Declarations/Struct.swift index caaec042..2d7366e7 100644 --- a/Sources/SyntaxKit/Declarations/Struct.swift +++ b/Sources/SyntaxKit/Declarations/Struct.swift @@ -70,13 +70,13 @@ public struct Struct: CodeBlock, Sendable { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } @@ -94,6 +94,7 @@ public struct Struct: CodeBlock, Sendable { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } return AttributeListSyntax(attributeElements) diff --git a/Sources/SyntaxKit/Declarations/TypeAlias.swift b/Sources/SyntaxKit/Declarations/TypeAlias.swift index 4b6fe9b9..06cb2175 100644 --- a/Sources/SyntaxKit/Declarations/TypeAlias.swift +++ b/Sources/SyntaxKit/Declarations/TypeAlias.swift @@ -35,6 +35,7 @@ public struct TypeAlias: CodeBlock, Sendable { private let existingType: String private var attributes: [AttributeInfo] = [] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { // `typealias` keyword token let keyword = TokenSyntax.keyword(.typealias, trailingTrivia: .space) diff --git a/Sources/SyntaxKit/ErrorHandling/Catch.swift b/Sources/SyntaxKit/ErrorHandling/Catch.swift index 1797179f..06c9e5d8 100644 --- a/Sources/SyntaxKit/ErrorHandling/Catch.swift +++ b/Sources/SyntaxKit/ErrorHandling/Catch.swift @@ -35,6 +35,7 @@ public struct Catch: CodeBlock { private let pattern: (any CodeBlock)? private let body: [any CodeBlock] + /// The SwiftSyntax representation of this catch clause. public var catchClauseSyntax: CatchClauseSyntax { // Build catch items (patterns) var catchItems: CatchItemListSyntax? @@ -51,40 +52,8 @@ public struct Catch: CodeBlock { let memberAccess = MemberAccessExprSyntax( base: typeName.isEmpty ? nil : ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(typeName))), - dot: .periodToken(), - name: .identifier(caseName) - ) - let patternWithTuple = PatternSyntax( - ValueBindingPatternSyntax( - bindingSpecifier: .keyword(.case, trailingTrivia: .space), - pattern: PatternSyntax( - ExpressionPatternSyntax( - expression: ExprSyntax(memberAccess) - ) - ) - ) - ) - // Actually, Swift's catch pattern for associated values is: .caseName(let a, let b) - // So we want: ExpressionPatternSyntax(MemberAccessExprSyntax + tuplePattern) - let tuplePattern = TuplePatternSyntax( - leftParen: .leftParenToken(), - elements: TuplePatternElementListSyntax( - enumCase.caseAssociatedValues.enumerated().map { index, associated in - TuplePatternElementSyntax( - pattern: PatternSyntax( - ValueBindingPatternSyntax( - bindingSpecifier: .keyword(.let, trailingTrivia: .space), - pattern: PatternSyntax( - IdentifierPatternSyntax(identifier: .identifier(associated.name)) - ) - ) - ), - trailingComma: index < enumCase.caseAssociatedValues.count - 1 - ? .commaToken(trailingTrivia: .space) : nil - ) - } - ), - rightParen: .rightParenToken() + period: .periodToken(), + declName: DeclReferenceExprSyntax(baseName: .identifier(caseName)) ) let patternSyntaxExpr = ExprSyntax( FunctionCallExprSyntax( @@ -163,6 +132,7 @@ public struct Catch: CodeBlock { ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { catchClauseSyntax } @@ -197,6 +167,7 @@ public struct Catch: CodeBlock { /// - Parameters: /// - enumCase: The enum case to catch. /// - content: A ``CodeBlockBuilder`` that provides the body of the catch clause. + /// - Returns: A configured ``Catch`` clause that matches the given enum case. public static func `catch`( _ enumCase: EnumCase, @CodeBlockBuilderResult _ content: () -> [any CodeBlock] diff --git a/Sources/SyntaxKit/ErrorHandling/Throw.swift b/Sources/SyntaxKit/ErrorHandling/Throw.swift index 51f92416..cdcb9bef 100644 --- a/Sources/SyntaxKit/ErrorHandling/Throw.swift +++ b/Sources/SyntaxKit/ErrorHandling/Throw.swift @@ -34,6 +34,7 @@ public import SwiftSyntax public struct Throw: CodeBlock { private let expr: any CodeBlock + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let expression: ExprSyntax if let enumCase = expr as? EnumCase { @@ -51,6 +52,7 @@ public struct Throw: CodeBlock { ) } + /// Creates a new instance. public init(_ expr: any CodeBlock) { self.expr = expr } diff --git a/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift b/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift new file mode 100644 index 00000000..4f660a08 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Bundle+ResolveLibPath.swift @@ -0,0 +1,90 @@ +// +// Bundle+ResolveLibPath.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension Bundle { + /// Bundle-relative lib dir name for the adjacent layout (`/lib`). + private static let libDirectoryName = "lib" + /// Bundle-relative lib dir path for the Homebrew layout + /// (`/../lib/skit`). + private static let homebrewLibSkitSubpath = "lib/skit" + + /// Resolves a directory containing `libSyntaxKit.dylib` + swiftmodules. + /// + /// Tries each non-nil entry in `candidates` in order; if any non-nil + /// candidate is not a SyntaxKit lib dir, throws `CLIError`. If every + /// candidate is absent, falls back to bundle-relative paths derived + /// from `executableURL`: + /// - `/lib` (adjacent layout) + /// - `/../lib/skit` (Homebrew layout) + /// + /// `executableURL` overrides the bundle's own executable location used to + /// derive the adjacent/Homebrew fallbacks; it defaults to `self.executableURL` + /// and exists so tests can point the fallbacks at a fixture tree. + public func resolveLibPath( + candidates: [String?], + fileManager: FileManager = .default, + executableURL: URL? = nil + ) throws -> String { + for candidate in candidates { + guard let candidate else { continue } + guard fileManager.isLibDir(candidate) else { + throw CLIError(message: "path does not look like a SyntaxKit lib dir: \(candidate)") + } + return candidate + } + + if let execURL = (executableURL ?? self.executableURL)?.resolvingSymlinksInPath() { + let execDir = execURL.deletingLastPathComponent() + + let adjacent = execDir.appendingPathComponent(Self.libDirectoryName).path + if fileManager.isLibDir(adjacent) { + return adjacent + } + + let brewLayout = execDir.deletingLastPathComponent() + .appendingPathComponent(Self.homebrewLibSkitSubpath).path + if fileManager.isLibDir(brewLayout) { + return brewLayout + } + } + + throw CLIError( + message: """ + Could not locate SyntaxKit lib directory. Looked for: + 1. explicit candidates (none provided or all empty) + 2. /lib/ (not found) + 3. /../lib/skit/ (not found) + Run Scripts/build-skit.sh to produce a self-contained + release bundle under .build/skit-release/. + """ + ) + } +} diff --git a/Sources/SyntaxKit/Execution/CLIError.swift b/Sources/SyntaxKit/Execution/CLIError.swift new file mode 100644 index 00000000..f41c3a1f --- /dev/null +++ b/Sources/SyntaxKit/Execution/CLIError.swift @@ -0,0 +1,42 @@ +// +// CLIError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Throwable error wrapper for skit's user-facing diagnostics. The message +/// is printed verbatim — keep it actionable (path, hint, next step). +public struct CLIError: Error, CustomStringConvertible { + /// The user-facing diagnostic, printed verbatim. + public let message: String + /// The error description, identical to `message`. + public var description: String { message } + + /// Creates an error carrying a verbatim user-facing `message`. + public init(message: String) { + self.message = message + } +} diff --git a/Sources/SyntaxKit/Execution/CollectInputsError.swift b/Sources/SyntaxKit/Execution/CollectInputsError.swift new file mode 100644 index 00000000..bb4a36e9 --- /dev/null +++ b/Sources/SyntaxKit/Execution/CollectInputsError.swift @@ -0,0 +1,41 @@ +// +// CollectInputsError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Typed error surfaced by `FileManager.collectInputs(at:)`. The two cases map +/// to the two distinct ways the input walk can fail, so the caller can tell a +/// directory it couldn't enumerate apart from a file whose resource values it +/// couldn't read. +public enum CollectInputsError: Error { + /// The directory could not be enumerated (`FileManager.enumerator` returned + /// nil). Carries a user-facing `CLIError` describing the path. + case cliError(CLIError) + /// A file's resource values (isRegularFile/isDirectory) could not be read; + /// carries the underlying Foundation error. + case resourceValuesFailure(any Error) +} diff --git a/Sources/SyntaxKit/Execution/ContentHasher.swift b/Sources/SyntaxKit/Execution/ContentHasher.swift new file mode 100644 index 00000000..7411a734 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ContentHasher.swift @@ -0,0 +1,72 @@ +// +// ContentHasher.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Non-cryptographic 64-bit FNV-1a hasher used to derive content-addressed +/// cache keys. The cache keys aren't security-critical — there's no +/// adversary trying to forge a collision — so we don't need a cryptographic +/// hash. 64 bits of output gives ~10⁻⁹ collision probability at 10⁶ cache +/// entries, which is well past anything we'll see in practice. +/// +/// FNV-1a is deterministic across processes and platforms (unlike the Swift +/// stdlib `Hasher`, whose seed is randomized per-process) — that +/// determinism is what makes it usable as an on-disk cache key. It is the +/// default `ContentHashing` conformer used by `OutputCache`. +public struct ContentHasher: ContentHashing { + private static let offsetBasis: UInt64 = 0xcbf2_9ce4_8422_2325 + private static let prime: UInt64 = 0x0000_0100_0000_01b3 + + /// Width of the zero-padded lowercase-hex digest (64 bits → 16 hex chars). + private static let hexWidth = 16 + + private var state: UInt64 = ContentHasher.offsetBasis + + /// Creates a hasher seeded with the FNV-1a offset basis. + public init() {} + + /// Mixes `data` into the running hash state via FNV-1a (XOR each byte into + /// the state, then multiply by the FNV prime). + public mutating func update(data: Data) { + for byte in data { + state ^= UInt64(byte) + state &*= ContentHasher.prime + } + } + + /// Returns the full 64-bit hash as a 16-char lowercase-hex string suitable + /// for use as a directory name. Built with `String(_:radix:)` rather than + /// `String(format: "%016x", …)` because the `%x` specifier consumes a 32-bit + /// `unsigned int`, which would silently truncate the digest to its low 32 + /// bits and halve the entropy described above. + public func finalize() -> String { + let hex = String(state, radix: 16) + return String(repeating: "0", count: max(0, Self.hexWidth - hex.count)) + hex + } +} diff --git a/Sources/SyntaxKit/Execution/ContentHashing.swift b/Sources/SyntaxKit/Execution/ContentHashing.swift new file mode 100644 index 00000000..d451a6e1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ContentHashing.swift @@ -0,0 +1,52 @@ +// +// ContentHashing.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// An incremental hasher used to derive `OutputCache` keys. Abstracted so the +/// cache's hashing is pluggable: inject a different conformer via +/// `OutputCache.init(…, makeHasher:)` to swap the algorithm (e.g. a +/// cryptographic digest) without touching the cache. +/// +/// Two contractual requirements callers depend on, which the default +/// `ContentHasher` (FNV-1a) satisfies and a replacement must too: +/// - **Determinism across processes and platforms.** Keys are persisted to +/// disk and compared on later runs, so the same byte stream must always +/// produce the same digest. (The stdlib `Hasher` is unsuitable — it is +/// per-process seeded.) +/// - **A digest usable as a directory name.** `finalize()` returns a string +/// that is safe to use as a path component. +public protocol ContentHashing { + /// Creates an empty hasher, ready to accept `update(data:)` calls. + init() + /// Mixes `data`'s bytes into the running digest. Order-significant. + mutating func update(data: Data) + /// Returns the final digest as a filesystem-safe string. + func finalize() -> String +} diff --git a/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift b/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift new file mode 100644 index 00000000..674357c7 --- /dev/null +++ b/Sources/SyntaxKit/Execution/EnvironmentProvider+SyntaxKitCacheRoot.swift @@ -0,0 +1,53 @@ +// +// EnvironmentProvider+SyntaxKitCacheRoot.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Constants for `EnvironmentProvider.syntaxKitCacheRoot(default:)`. Nested +/// in a private enum to satisfy the "no globals" rule while letting the +/// protocol extension below reference them. +private enum SyntaxKitCacheRootConstants { + /// Environment variable pointing at the XDG cache root, if set. + static let xdgCacheHomeEnvKey = "XDG_CACHE_HOME" + /// Leaf directory appended to the XDG cache root for skit's caches. + static let cacheDirectoryName = "syntaxkit" +} + +extension EnvironmentProvider { + /// Root for all skit caches: `/syntaxkit` when that env + /// var is set and non-empty, otherwise `defaultRoot` (typically the + /// platform's home-relative cache dir). + internal func syntaxKitCacheRoot(default defaultRoot: URL) -> URL { + if let xdg = environment[SyntaxKitCacheRootConstants.xdgCacheHomeEnvKey], !xdg.isEmpty { + return URL(fileURLWithPath: xdg) + .appendingPathComponent(SyntaxKitCacheRootConstants.cacheDirectoryName) + } + return defaultRoot + } +} diff --git a/Sources/SyntaxKit/Execution/EnvironmentProvider.swift b/Sources/SyntaxKit/Execution/EnvironmentProvider.swift new file mode 100644 index 00000000..e0132f5e --- /dev/null +++ b/Sources/SyntaxKit/Execution/EnvironmentProvider.swift @@ -0,0 +1,44 @@ +// +// EnvironmentProvider.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// The slice of `ProcessInfo` `OutputCache` depends on. Abstracted into a +/// protocol so tests can inject a fixed environment without subclassing +/// `ProcessInfo` — `swift-corelibs-foundation` declares `ProcessInfo` as +/// `final` with `environment` on an extension, so subclass-and-override +/// doesn't compile on Linux/Windows. +public protocol EnvironmentProvider: Sendable { + /// Process environment variables. + var environment: [String: String] { get } + /// Identifier of the current process, used to namespace staging dirs. + var processIdentifier: Int32 { get } +} + +extension ProcessInfo: EnvironmentProvider {} diff --git a/Sources/SyntaxKit/Execution/FileEnumerationError.swift b/Sources/SyntaxKit/Execution/FileEnumerationError.swift new file mode 100644 index 00000000..2e68f19c --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileEnumerationError.swift @@ -0,0 +1,39 @@ +// +// FileEnumerationError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// A recursive regular-file enumeration failure, decoupled from any domain so +/// the caller maps it onto its own error type. +internal enum FileEnumerationError: Error { + /// The directory couldn't be enumerated at all. + case notEnumerable(URL) + /// A file's resource values couldn't be read mid-walk. + case resourceValuesUnavailable(URL, any Error) +} diff --git a/Sources/SyntaxKit/Execution/FileFingerprint.swift b/Sources/SyntaxKit/Execution/FileFingerprint.swift new file mode 100644 index 00000000..73ed3568 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileFingerprint.swift @@ -0,0 +1,39 @@ +// +// FileFingerprint.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Size + modification date of a file: the change-detection inputs behind a +/// content fingerprint/stamp. +internal struct FileFingerprint: Equatable { + /// File size in bytes (0 when the attribute is absent). + internal let size: Int + /// Last-modification date (Unix epoch when the attribute is absent). + internal let modificationDate: Date +} diff --git a/Sources/SyntaxKit/Execution/FileManager+Execution.swift b/Sources/SyntaxKit/Execution/FileManager+Execution.swift new file mode 100644 index 00000000..51b61bfb --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileManager+Execution.swift @@ -0,0 +1,91 @@ +// +// FileManager+Execution.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension FileManager { + /// Library product name whose platform-specific dylib marks a lib dir. + private static let syntaxKitProductName = "SyntaxKit" + /// File extension identifying SyntaxKit DSL input files. + private static let swiftFileExtension = "swift" + /// Filename prefix marking a source as "not an input" (skipped in batches). + private static let nonInputFilePrefix = "_" + + /// True if `path` is a directory containing `libSyntaxKit.{dylib,so}`. + internal func isLibDir(_ path: String) -> Bool { + guard pathKind(atPath: path) == .directory else { + return false + } + return pathKind(atPath: "\(path)/\(Self.syntaxKitProductName.dylibFilename)") != .missing + } + + /// `/` fingerprint of `libSyntaxKit.{dylib,so}` under + /// `libPath`, or nil if unreadable. Catches in-place rebuilds without a + /// version bump. + internal func libStamp(libPath: String) -> String? { + let dylib = "\(libPath)/\(Self.syntaxKitProductName.dylibFilename)" + return fingerprint(atPath: dylib).map { + "\($0.size)/\(Int($0.modificationDate.timeIntervalSince1970))" + } + } + + /// Returns every `.swift` file under `inputDir` (recursive), sorted, with + /// hidden files and files prefixed by `_` removed. The recursive walk and + /// sort come from `regularFiles(under:)`; this method adds only the SyntaxKit + /// input convention (`.swift` extension, skip the `_`-prefixed "not an input" + /// sources). + /// + /// Throws `CollectInputsError.cliError` when the directory can't be + /// enumerated, or `.resourceValuesFailure` when a file's resource values + /// can't be read — both are bulk failures with nothing per-file to report. + /// + /// `public` so callers (e.g. the skit CLI) can collect inputs explicitly and + /// feed the in-memory sources to `Runner.render(sources:)`. + public func collectInputs(at inputDir: URL) throws(CollectInputsError) -> [URL] { + let files: [URL] + do { + files = try regularFiles(under: inputDir) + } catch { + // `error` is typed `FileEnumerationError`; map each case onto the + // collect-specific error the caller already presents. + switch error { + case .notEnumerable(let directory): + throw .cliError(CLIError(message: "could not enumerate \(directory.path)")) + case .resourceValuesUnavailable(_, let underlying): + throw .resourceValuesFailure(underlying) + } + } + + return + files + .filter { $0.pathExtension == Self.swiftFileExtension } + .filter { !$0.lastPathComponent.hasPrefix(Self.nonInputFilePrefix) } + .map(\.standardizedFileURL) + } +} diff --git a/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift b/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift new file mode 100644 index 00000000..96af3362 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileManager+FileSystem.swift @@ -0,0 +1,117 @@ +// +// FileManager+FileSystem.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension FileManager { + /// Classifies what exists at `path` in a single stat — `.missing`, `.file`, + /// or `.directory`. + internal func pathKind(atPath path: String) -> PathKind { + var isDirectory: ObjCBool = false + guard fileExists(atPath: path, isDirectory: &isDirectory) else { + return .missing + } + return isDirectory.boolValue ? .directory : .file + } + + /// Size + modification date of the file at `path`, or `nil` if its attributes + /// can't be read. Absent individual attributes default to `0` / the Unix + /// epoch so a readable item always yields a fingerprint. + internal func fingerprint(atPath path: String) -> FileFingerprint? { + guard let attributes = try? attributesOfItem(atPath: path) else { + return nil + } + let size = (attributes[.size] as? NSNumber)?.intValue ?? 0 + let modificationDate = + (attributes[.modificationDate] as? Date) + ?? Date(timeIntervalSince1970: 0) + return FileFingerprint(size: size, modificationDate: modificationDate) + } + + /// Every regular (non-directory) file under `directory`, recursively, with + /// hidden files skipped and the result sorted by path. Sorted because callers + /// generally want deterministic ordering, and doing it here keeps that + /// guarantee in one place. Unfiltered by extension — the caller decides what + /// counts as relevant. + internal func regularFiles(under directory: URL) throws(FileEnumerationError) -> [URL] { + guard + let enumerator = enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + throw .notEnumerable(directory) + } + + var result: [URL] = [] + for case let url as URL in enumerator { + // `.skipsHiddenFiles` keys off the dot-prefix convention on Unix but the + // `FILE_ATTRIBUTE_HIDDEN` attribute on Windows, so a dot-prefixed entry + // slips through there. Filter dot-prefixed components explicitly to keep + // the same hidden-file semantics on every platform. + if hasHiddenComponent(url, under: directory) { continue } + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) + } catch { + throw .resourceValuesUnavailable(url, error) + } + if values.isDirectory == true { continue } + guard values.isRegularFile == true else { continue } + result.append(url) + } + return result.sorted { $0.path < $1.path } + } + + /// True when `url` lies under a dot-prefixed path component relative to + /// `directory`. Mirrors `.skipsHiddenFiles` on platforms (Windows) where that + /// option keys off the hidden *attribute* rather than the dot-prefix + /// convention — and matches Unix's behavior of not descending into hidden + /// directories by also excluding files nested under them. + private func hasHiddenComponent(_ url: URL, under directory: URL) -> Bool { + let base = directory.standardizedFileURL.pathComponents + let full = url.standardizedFileURL.pathComponents + guard full.count > base.count else { + return false + } + return full[base.count...].contains { $0.hasPrefix(".") } + } + + /// Writes `data` to `destination`, first creating any missing intermediate + /// directories. `public` so a caller writing a rendered batch (e.g. the skit + /// CLI) can mirror outputs into a destination tree. + public func writeData(_ data: Data, to destination: URL) throws { + try createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: destination) + } +} diff --git a/Sources/SyntaxKit/Execution/FileOutcome.swift b/Sources/SyntaxKit/Execution/FileOutcome.swift new file mode 100644 index 00000000..491111a0 --- /dev/null +++ b/Sources/SyntaxKit/Execution/FileOutcome.swift @@ -0,0 +1,76 @@ +// +// FileOutcome.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Per-input result of a batch render. `stdout` is the rendered Swift source +/// (empty on failure). `stderr` carries the (possibly path-rewritten) +/// diagnostics from the spawned `swift`; it may be present whether or not the +/// input succeeded (e.g. a successful render that emitted warnings). `result` +/// is `nil` when the input rendered successfully (so `stdout` is valid and the +/// caller may write it), and carries the `RunError` when the input could not be +/// rendered (`.renderFailed`/`.unexpected`). +/// +/// `Runner.render(sources:)` returns `[FileOutcome]`; per-input failures are +/// captured here, not thrown, so a single bad input doesn't tear the batch +/// down. The SDK does not write `stdout` anywhere — the caller owns that. +public struct FileOutcome: Sendable { + /// The input file this outcome describes. + public let input: URL + /// The rendered Swift source for this input; empty when `result` is non-nil. + public let stdout: Data + /// The (possibly path-rewritten) `swift` diagnostics for this input. + public let stderr: String + /// `nil` when the input rendered successfully; the `RunError` otherwise. + public let result: RunError? + + /// Creates an outcome for one rendered (or failed) input. + public init( + input: URL, + stdout: Data, + stderr: String, + result: RunError? + ) { + self.input = input + self.stdout = stdout + self.stderr = stderr + self.result = result + } +} + +extension Array where Element == FileOutcome { + /// Number of inputs whose `result` is non-nil — the signal the caller uses + /// to map a partially-failed batch to a non-zero exit (or whatever failure + /// semantics fit the host). + public var failureCount: Int { + reduce(into: 0) { count, outcome in + if outcome.result != nil { count += 1 } + } + } +} diff --git a/Sources/SyntaxKit/Execution/OutputCache.swift b/Sources/SyntaxKit/Execution/OutputCache.swift new file mode 100644 index 00000000..7ccceb16 --- /dev/null +++ b/Sources/SyntaxKit/Execution/OutputCache.swift @@ -0,0 +1,196 @@ +// +// OutputCache.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// On-disk cache of rendered skit output, content-keyed so a re-run on +/// unchanged input skips the `swift` spawn entirely. +/// +/// `Sendable`: the stored state is a `@Sendable` `FileManager` factory plus +/// an `EnvironmentProvider` and value types, so the single instance can be +/// shared safely across the concurrent `runOne` tasks in directory mode. The +/// default singletons used in production (and the typical test doubles) are +/// thread-safe for the operations we invoke. +public struct OutputCache: Sendable { + /// Bumped when the cache layout changes in a way that requires invalidation. + private static let schemaVersion = "v1" + + #if os(macOS) + /// macOS home-relative cache subpath (under `~`). + private static let macOSCacheSubpath = "Library/Caches/com.brightdigit.SyntaxKit" + #else + /// Linux home-relative cache subpath (under `~`). + private static let linuxCacheSubpath = ".cache/syntaxkit" + #endif + /// Leaf directory holding the rendered outputs within the cache root. + private static let outputsDirectoryName = "outputs" + /// Filename of the rendered Swift source stored per cache key. + private static let outputFileName = "output.swift" + /// Prefix for the per-writer staging directory used for atomic stores. + private static let stagingDirectoryPrefix = "tmp" + /// Environment-variable prefixes mixed into the cache key. + private static let skitEnvPrefix = "SKIT_" + private static let syntaxKitEnvPrefix = "SYNTAXKIT_" + + /// Home-relative cache root used when `XDG_CACHE_HOME` is unset: macOS + /// `~/Library/Caches/...`, else Linux `~/.cache/syntaxkit`. The home dir is + /// fixed for the process lifetime, so this is computed once. + private static let defaultCacheRoot: URL = { + let home = NSHomeDirectory() + #if os(macOS) + return URL(fileURLWithPath: home) + .appendingPathComponent(macOSCacheSubpath) + #else + return URL(fileURLWithPath: home).appendingPathComponent(linuxCacheSubpath) + #endif + }() + + /// `/outputs/`. Populated once at init; per-key + /// directories are derived from it on demand. + private let root: URL + private let fileManager: @Sendable () -> FileManager + private let environmentProvider: any EnvironmentProvider + /// Factory for the per-key hasher. Pluggable so the cache's hashing algorithm + /// can be swapped; defaults to `ContentHasher` (FNV-1a). A factory rather than + /// a stored instance because `key(forInput:libPath:)` needs a fresh, empty + /// hasher per call. + private let makeHasher: @Sendable () -> any ContentHashing + + /// Verbatim `swift --version` output captured once for the lifetime of + /// this cache, so per-input key derivation doesn't re-spawn `swift`. + /// nil if capture failed before construction. + private let swiftVersion: String? + + /// Creates a cache rooted under the SyntaxKit cache directory, keyed in part + /// by the captured `swiftVersion`. `fileManager`/`environmentProvider` are + /// injectable for testing; `makeHasher` is injectable to plug in a different + /// `ContentHashing` algorithm (defaults to `ContentHasher`). + public init( + swiftVersion: String?, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + environmentProvider: any EnvironmentProvider = ProcessInfo.processInfo, + makeHasher: @escaping @Sendable () -> any ContentHashing = { ContentHasher() } + ) { + self.root = environmentProvider.syntaxKitCacheRoot(default: Self.defaultCacheRoot) + .appendingPathComponent(Self.outputsDirectoryName) + self.swiftVersion = swiftVersion + self.fileManager = fileManager + self.environmentProvider = environmentProvider + self.makeHasher = makeHasher + } + + /// 64-bit content hash over (schema version, input source bytes, swift + /// version, libSyntaxKit stamp, sorted SKIT_*/SYNTAXKIT_* env vars). Any + /// change in these inputs produces a fresh key and forces a recompile. + /// See `ContentHasher` for the choice of FNV-1a over a cryptographic hash. + public func key(forInput source: String, libPath: String) -> String { + var hasher = makeHasher() + // Schema version: bump to invalidate every existing cache entry at once. + hasher.update(data: Data(Self.schemaVersion.utf8)) + // Input source bytes: the primary driver of the key. + hasher.update(data: Data(source.utf8)) + + // Toolchain version. Different `swift` builds emit different bytes for + // the same DSL input. + if let swiftVersion { + hasher.update(data: Data(swiftVersion.utf8)) + } + // libSyntaxKit stamp. A rebuilt dylib can change the rendered output + // even without a Swift-version bump. + if let stamp = fileManager().libStamp(libPath: libPath) { + hasher.update(data: Data(stamp.utf8)) + } + + // SKIT_*/SYNTAXKIT_* env vars. Sorted so the cache key is stable, and + // NUL-terminated so `"AB=" + "C"` doesn't collide with `"A=" + "BC"`. + let env = environmentProvider.environment + .filter { + $0.key.hasPrefix(Self.skitEnvPrefix) || $0.key.hasPrefix(Self.syntaxKitEnvPrefix) + } + .sorted { $0.key < $1.key } + for (key, value) in env { + hasher.update(data: Data("\(key)=\(value)\0".utf8)) + } + + return hasher.finalize() + } + + /// Returns the cached rendered output for `key`, or nil on miss. + public func lookup(key: String) -> Data? { + try? Data(contentsOf: directory(for: key).appendingPathComponent(Self.outputFileName)) + } + + /// Removes every cached entry by deleting the outputs root. A no-op if the + /// cache was never populated, so callers can purge unconditionally. + public func clear() throws { + guard fileManager().pathKind(atPath: root.path) != .missing else { + return + } + try fileManager().removeItem(at: root) + } + + /// Atomically stores `data` under `key`. Concurrent writers race via a + /// `tmp../` staging dir + rename; the loser drops their copy. + public func store(key: String, data: Data) throws { + let cacheRoot = directory(for: key) + let final = cacheRoot.appendingPathComponent(Self.outputFileName) + + // Ensure the parent of the cache key dir exists. The key dir itself is + // installed by the atomic rename below. + try fileManager().createDirectory( + at: cacheRoot.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Stage the payload in a per-pid + uuid sibling dir so it can be renamed + // into place as a single atomic step. + let staging = cacheRoot.deletingLastPathComponent() + .appendingPathComponent( + "\(Self.stagingDirectoryPrefix).\(environmentProvider.processIdentifier).\(UUID().uuidString)" + ) + try fileManager().createDirectory(at: staging, withIntermediateDirectories: true) + try data.write(to: staging.appendingPathComponent(Self.outputFileName)) + + // Atomic rename into the cache path. If a peer already populated this + // key, swallow the rename error and drop our staging copy. Re-throw only + // if the destination is still missing afterwards. + do { + try fileManager().moveItem(at: staging, to: cacheRoot) + } catch { + try? fileManager().removeItem(at: staging) + if fileManager().pathKind(atPath: final.path) == .missing { + throw error + } + } + } + + private func directory(for key: String) -> URL { + root.appendingPathComponent(key) + } +} diff --git a/Sources/SyntaxKit/Execution/PathKind.swift b/Sources/SyntaxKit/Execution/PathKind.swift new file mode 100644 index 00000000..75fb74c2 --- /dev/null +++ b/Sources/SyntaxKit/Execution/PathKind.swift @@ -0,0 +1,40 @@ +// +// PathKind.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// What exists at a filesystem path — the result of a single existence check, +/// replacing the `fileExists(atPath:isDirectory:)` + `ObjCBool` idiom with a +/// value callers can `switch` over. +internal enum PathKind: Equatable { + /// Nothing exists at the path. + case missing + /// A regular file (or any non-directory) exists at the path. + case file + /// A directory exists at the path. + case directory +} diff --git a/Sources/SyntaxKit/Execution/ProcessResult.swift b/Sources/SyntaxKit/Execution/ProcessResult.swift new file mode 100644 index 00000000..caab2357 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ProcessResult.swift @@ -0,0 +1,49 @@ +// +// ProcessResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Raw outcome of rendering one input — what `processFile` returns to its +/// caller. `exitCode == 0` indicates the spawned `swift` succeeded (or that +/// the output cache hit, which is treated identically). +public struct ProcessResult: Sendable { + /// Exit status of the spawned `swift`. `0` indicates success (or a cache hit). + public let exitCode: Int32 + /// Bytes the wrapped program emitted to standard output — the rendered source. + public let stdout: Data + /// Compiler diagnostics from the spawned `swift`. Empty when it was silent. + public let stderr: String + + /// Creates a result from a finished (or cache-served) render. + public init(exitCode: Int32, stdout: Data, stderr: String) { + self.exitCode = exitCode + self.stdout = stdout + self.stderr = stderr + } +} diff --git a/Sources/SyntaxKit/Execution/RenderInput.swift b/Sources/SyntaxKit/Execution/RenderInput.swift new file mode 100644 index 00000000..5df61e5d --- /dev/null +++ b/Sources/SyntaxKit/Execution/RenderInput.swift @@ -0,0 +1,50 @@ +// +// RenderInput.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// One in-memory input for a batch render: the source bytes paired with the +/// URL that identifies them. `Runner.render(sources:)` never opens `url` — it +/// is a *label*, used for `#sourceLocation` mapping and stderr path-rewriting, +/// and carried back on the matching `FileOutcome` so the caller can map the +/// rendered output to a destination (e.g. by rerooting `url` onto an output +/// tree). The caller is responsible for reading the file into `source`. +public struct RenderInput: Sendable { + /// The URL identifying this input. Used as a diagnostic label and as the + /// key the caller reroots when writing output; never opened by the SDK. + public let url: URL + /// The input's source bytes, already read into memory by the caller. + public let source: String + + /// Pairs an identifying `url` with its already-loaded `source`. + public init(url: URL, source: String) { + self.url = url + self.source = source + } +} diff --git a/Sources/SyntaxKit/Execution/RenderTaskResult.swift b/Sources/SyntaxKit/Execution/RenderTaskResult.swift new file mode 100644 index 00000000..dbdc4bf1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RenderTaskResult.swift @@ -0,0 +1,40 @@ +// +// RenderTaskResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Payload the per-input render `TaskGroup` yields back to `render(sources:)`. +/// Failures are captured (not thrown) so a single bad input doesn't tear down +/// the group; `processFile`'s heterogeneous Foundation/Subprocess throws are +/// normalized into `RunError` (typically `.unexpected`) by `runOne`. `Runner` +/// then folds each value into a public `FileOutcome`. +internal struct RenderTaskResult: Sendable { + internal let input: URL + internal let result: Result +} diff --git a/Sources/SyntaxKit/Execution/RunError.swift b/Sources/SyntaxKit/Execution/RunError.swift new file mode 100644 index 00000000..47110f0e --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunError.swift @@ -0,0 +1,48 @@ +// +// RunError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Typed error surfaced by `Runner`. It decouples the renderer from any +/// particular caller: `Runner` reports *what* went wrong, and the caller +/// (CLI, build plugin, in-process driver) decides how to present it. +public enum RunError: Error { + /// The input path was invalid — missing, or a directory given without an + /// output directory. + case invalidInput(String) + /// Single-file render: the spawned `swift` exited non-zero. Carries that + /// code (e.g. a compile failure, `124` on timeout, `128 + signal`) and + /// the (path-rewritten) stderr the toolchain emitted, so the caller can + /// surface diagnostics without having to fish them out elsewhere. Also + /// carries the session's `toolchainVerification`: when it isn't `.verified`, + /// the failure may stem from a Swift-toolchain mismatch the check couldn't + /// rule out, which the caller can hint at. + case renderFailed(exitCode: Int32, stderr: String, toolchain: ToolchainVerification) + /// A wrapped Foundation/Subprocess failure (file read/write, spawn error) + /// that has no dedicated mapping. + case unexpected(any Error) +} diff --git a/Sources/SyntaxKit/Execution/RunInput.swift b/Sources/SyntaxKit/Execution/RunInput.swift new file mode 100644 index 00000000..68608824 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunInput.swift @@ -0,0 +1,61 @@ +// +// RunInput.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Whether an input path resolves to a single `.swift` file or a directory +/// of them — the two modes a `Runner` caller dispatches into. Built by +/// `resolve(input:output:)`, which stats the path and enforces the per-mode +/// output rules so the caller can `switch` on a settled value instead of +/// juggling an `ObjCBool`. +public enum RunInput { + /// A single input file. `outputPath` is whatever the caller intends to + /// do with the rendered bytes (write to a file, ignore, etc.); `Runner` + /// itself does not act on it. + case singleFile(inputPath: String, outputPath: String?) + /// A directory of inputs mirrored into `outputDir` (always required). + case directory(inputDir: String, outputDir: String) + + /// Classifies `input` by stat: existing directory → `.directory`, + /// existing file → `.singleFile`. Throws `RunError.invalidInput` if the path + /// doesn't exist, or if a directory input wasn't given an explicit output. + public static func resolve(input: String, output: String?) throws(RunError) -> RunInput { + switch FileManager.default.pathKind(atPath: input) { + case .missing: + throw RunError.invalidInput("input does not exist: \(input)") + case .directory: + guard let output else { + throw RunError.invalidInput("directory inputs require -o ") + } + return .directory(inputDir: input, outputDir: output) + case .file: + return .singleFile(inputPath: input, outputPath: output) + } + } +} diff --git a/Sources/SyntaxKit/Execution/Runner+Directory.swift b/Sources/SyntaxKit/Execution/Runner+Directory.swift new file mode 100644 index 00000000..3d34b13b --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner+Directory.swift @@ -0,0 +1,126 @@ +// +// Runner+Directory.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension Runner { + /// Renders a batch of in-memory inputs concurrently (up to the active core + /// count) and returns a per-input `FileOutcome` carrying the rendered + /// `stdout`, diagnostics, and any `RunError`. The SDK reads nothing and + /// writes nothing — the caller supplies the sources (e.g. via + /// `FileManager.collectInputs` + reads) and writes each successful + /// `stdout` wherever it likes (e.g. by rerooting `outcome.input`). A failure + /// on one input does not affect its peers; inspect `failureCount` (and + /// per-outcome `stderr`) to decide presentation. An empty input set yields an + /// empty array. + public func render(sources: [RenderInput]) async -> [FileOutcome] { + guard !sources.isEmpty else { + return [] + } + + // Render every input with bounded concurrency, then fold each raw result + // into the public per-input outcome. + let renderResults = await processInputs(sources) + return renderResults.map(outcome(for:)) + } + + /// Folds one raw `RenderTaskResult` into a public `FileOutcome`: a zero-exit + /// render yields its `stdout` with `result == nil`; a non-zero exit becomes + /// `.renderFailed` (carrying the session toolchain); a captured throw is + /// surfaced as-is. No file IO — writing is the caller's job. + private func outcome(for taskResult: RenderTaskResult) -> FileOutcome { + switch taskResult.result { + case .success(let process) where process.exitCode == 0: + return FileOutcome( + input: taskResult.input, + stdout: process.stdout, + stderr: process.stderr, + result: nil + ) + case .success(let process): + return FileOutcome( + input: taskResult.input, + stdout: Data(), + stderr: process.stderr, + result: .renderFailed( + exitCode: process.exitCode, + stderr: process.stderr, + toolchain: toolchainVerification + ) + ) + case .failure(let error): + return FileOutcome(input: taskResult.input, stdout: Data(), stderr: "", result: error) + } + } + + /// Renders every input through `runOne` with bounded concurrency. The cap is + /// the active core count so a 200-file batch doesn't fork 200 simultaneous + /// `swift` processes: the group is seeded up to the cap, then refilled one + /// task per completion until the inputs are exhausted. + private func processInputs(_ inputs: [RenderInput]) async -> [RenderTaskResult] { + let maxConcurrent = max(1, ProcessInfo.processInfo.activeProcessorCount) + + var renderResults: [RenderTaskResult] = [] + renderResults.reserveCapacity(inputs.count) + var iterator = inputs.makeIterator() + + await withTaskGroup(of: RenderTaskResult.self) { group in + // Seed the group up to the concurrency cap… + for _ in 0.. RenderTaskResult { + do { + let result = try await processFile(source: input.source, originalPath: input.url.path) + return RenderTaskResult(input: input.url, result: .success(result)) + } catch let error as RunError { + return RenderTaskResult(input: input.url, result: .failure(error)) + } catch { + return RenderTaskResult(input: input.url, result: .failure(.unexpected(error))) + } + } +} diff --git a/Sources/SyntaxKit/Execution/Runner+Session.swift b/Sources/SyntaxKit/Execution/Runner+Session.swift new file mode 100644 index 00000000..c345a679 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner+Session.swift @@ -0,0 +1,103 @@ +// +// Runner+Session.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension Runner { + /// Brings up a render-ready `Runner`: resolves the libSyntaxKit directory + /// from `libCandidates` (falling back to bundle-relative layouts), optionally + /// gates on the bundle/local toolchain comparison, and wires up the output + /// cache. + /// + /// Platform-agnostic by construction — the two inputs that need a Subprocess + /// backend are injected by the caller: the already-captured `swiftVersion` + /// (`swift --version` output, or nil if capture failed) and the `run` closure + /// that actually spawns `swift` for one `SwiftInvocation`. + /// + /// - Throws: `RunnerSetupError` for a lookup or toolchain failure, so the caller + /// owns the presentation and exit mapping. + public init( + libCandidates: [String?], + swiftVersion: String?, + enforceToolchainCheck: Bool, + useCache: Bool, + timeoutSeconds: Int, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + run: @Sendable @escaping (SwiftInvocation) async throws -> SwiftRunOutcome + ) throws(RunnerSetupError) { + // 1. Resolve the libSyntaxKit bundle dir. Failure is fatal — there's no + // dylib + swiftmodules to link against without it. + let libPath: String + do { + libPath = try Bundle.main.resolveLibPath( + candidates: libCandidates, + fileManager: fileManager() + ) + } catch { + throw RunnerSetupError.libResolutionFailed(error) + } + + // 2. Compare the bundle's recorded `swift --version` against the local one. + // swiftmodules aren't reliably forward-compatible across compiler versions, + // so a mismatch is surfaced rather than letting the spawned `swift` emit a + // cryptic module-version diagnostic. A *mismatch* fails setup; anything else + // is remembered (no IO here — the SDK stays silent) so a later render + // failure can hint that an unverified toolchain may be the cause. + let verification: ToolchainVerification + if enforceToolchainCheck { + switch ToolchainCheckResult(libPath: libPath, swiftVersion: swiftVersion) { + case .match: + verification = .verified + case .stampMissing: + verification = .unverified + case .mismatch(let bundle, let local): + throw RunnerSetupError.toolchainMismatch(bundle: bundle, local: local) + } + } else { + verification = .notChecked + } + + // 3. Build the output cache (nil when disabled). The captured `swiftVersion` + // is bound in so per-input key derivation doesn't re-spawn `swift`. + let cache: OutputCache? = + useCache + ? OutputCache(swiftVersion: swiftVersion, fileManager: fileManager()) + : nil + + // 4. Delegate to the designated initializer, binding the spawn closure. + self.init( + libPath: libPath, + cache: cache, + timeoutSeconds: timeoutSeconds, + toolchainVerification: verification, + fileManager: fileManager(), + run: run + ) + } +} diff --git a/Sources/SyntaxKit/Execution/Runner.swift b/Sources/SyntaxKit/Execution/Runner.swift new file mode 100644 index 00000000..6da61709 --- /dev/null +++ b/Sources/SyntaxKit/Execution/Runner.swift @@ -0,0 +1,225 @@ +// +// Runner.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +// Render lifecycle (per `Runner` call): +// 1. Bundle.main.resolveLibPath — find lib/ (explicit flag → env → adjacent → brew) +// 2. ToolchainCheckResult.init — compare bundle stamp to `swift --version` +// 3. Runner.render(source:) / .render(sources:) — single- or batch-input mode +// 4. processFile (per input) — cache lookup → wrap → spawn → cache store +// 5. wrap — hoist imports, wrap body in Group { … }, #sourceLocation +// 6. runSwift — spawn `swift` with timeout watchdog + +/// Renders SyntaxKit DSL inputs into Swift source. `Runner` is the SDK-shaped +/// entry point: methods return rendered data (`SingleFileRender`) or +/// structured per-file outcomes (`[FileOutcome]`) and throw typed +/// `RunError`s; callers — CLI, build plugin, in-process driver — decide what +/// to do with stdout, stderr, exit codes, and so on. +/// +/// Constructed once per render session; `Sendable` so a single value can be +/// shared across the concurrent per-input tasks in directory mode. +public struct Runner: Sendable { + /// Exit code returned when the spawned `swift` is killed by skit's timeout + /// watchdog. Matches POSIX `timeout(1)`. + private static let timeoutExitCode: Int32 = 124 + + /// Prefix for the per-invocation temp dir holding the wrapped input. + private static let tempDirectoryPrefix = "skit" + /// Filename of the wrapped Swift program spilled into the temp dir. + private static let wrappedInputFileName = "Input.wrapped.swift" + + /// Directory holding `libSyntaxKit.{dylib,so}` + swiftmodules; reused for + /// the spawned `swift`'s `-I`/`-L`/`-rpath` flags. + private let libPath: String + /// Output cache shared across every input, or nil under `--no-cache`. + private let cache: OutputCache? + /// Per-input watchdog in seconds; `0` opts out of the timeout race. + private let timeoutSeconds: Int + /// Backend that actually spawns `swift` for one `SwiftInvocation`. Injected + /// by the caller (skit supplies a Subprocess-based implementation). + private let run: @Sendable (SwiftInvocation) async throws -> SwiftRunOutcome + /// FileManager factory used for tmp-dir creation, batch enumeration, and + /// per-input writes. Injectable so tests can swap in a fake; defaults to + /// `.default` in production. + internal let fileManager: @Sendable () -> FileManager + /// Whether this session's bundle/local Swift toolchain was confirmed + /// compatible. Defaults to `.notChecked` for callers that construct a + /// `Runner` directly; the session initializer (`Runner+Session.swift`) sets + /// the real result. Carried onto `RunError.renderFailed`. + public let toolchainVerification: ToolchainVerification + + /// Creates a runner bound to a lib dir, optional output cache, per-input + /// timeout, and the backend closure that spawns `swift`. + public init( + libPath: String, + cache: OutputCache?, + timeoutSeconds: Int, + toolchainVerification: ToolchainVerification = .notChecked, + fileManager: @autoclosure @escaping @Sendable () -> FileManager = .default, + run: @Sendable @escaping (SwiftInvocation) async throws -> SwiftRunOutcome + ) { + self.libPath = libPath + self.cache = cache + self.timeoutSeconds = timeoutSeconds + self.toolchainVerification = toolchainVerification + self.fileManager = fileManager + self.run = run + } + + // MARK: - Single-file mode + + /// Renders one in-memory input and returns the rendered bytes plus any + /// compiler diagnostics. The SDK reads no input and writes no output; + /// `originalPath` is only a diagnostic label (`#sourceLocation` + stderr + /// path-rewriting) — pass `nil` for an anonymous snippet. + /// On a non-zero subprocess exit, throws `RunError.renderFailed` carrying the + /// toolchain's diagnostic. Any Foundation/Subprocess failure (the internal + /// temp-wrapper write, spawn) is wrapped in `RunError.unexpected`. + public func render( + source: String, + originalPath: String? = nil + ) async throws(RunError) -> SingleFileRender { + let result: ProcessResult + do { + result = try await processFile(source: source, originalPath: originalPath) + } catch let error as RunError { + throw error + } catch { + throw RunError.unexpected(error) + } + // Non-zero subprocess exit is reported as a typed failure carrying both + // the code and the (path-rewritten) toolchain diagnostic. + guard result.exitCode == 0 else { + throw RunError.renderFailed( + exitCode: result.exitCode, + stderr: result.stderr, + toolchain: toolchainVerification + ) + } + return SingleFileRender(stdout: result.stdout, stderr: result.stderr) + } + + // MARK: - Per-file work + + /// The per-input render pipeline: consult the output cache → (on miss) wrap → + /// spawn `swift` → rewrite diagnostics → store in the cache. `source` comes + /// from the caller; `originalPath` is only a `#sourceLocation`/stderr label + /// (never opened). The temp wrapper is created in a per-run tmp dir and torn + /// down by `defer`. `internal` so `Runner+Directory.swift` can reuse it. + internal func processFile(source: String, originalPath: String?) async throws -> ProcessResult { + // Diagnostics label: standardize a given path, else a synthetic snippet name. + let absoluteInputPath = + originalPath.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + ?? "source.swift" + + // Compute the output cache key (nil under `--no-cache` or when the cache + // root couldn't be derived at startup). Mixes input bytes, toolchain + // version, libSyntaxKit stamp, and sorted SKIT_*/SYNTAXKIT_* env vars + // — see `OutputCache.key(forInput:libPath:)`. + let cacheKey: String? = cache?.key(forInput: source, libPath: libPath) + // Cache hit: skip the wrap+spawn entirely and return the stored output. + if let cache, let cacheKey, let cached = cache.lookup(key: cacheKey) { + return ProcessResult(exitCode: 0, stdout: cached, stderr: "") + } + + // Wrap the user's input into a complete Swift program that imports + // SyntaxKit, runs the body inside a Group { … } builder, and prints the + // result. See `WrappedSource` for the exact template. + let wrapped = WrappedSource(source: source, originalPath: absoluteInputPath).rendered + + // Spill the wrapped program to a per-invocation temp dir. The dir is + // cleaned up unconditionally so a failed spawn doesn't leak files. + let tmpDir = fileManager().temporaryDirectory + .appendingPathComponent("\(Self.tempDirectoryPrefix)-\(UUID().uuidString)") + try fileManager().createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? fileManager().removeItem(at: tmpDir) } + + let wrappedURL = tmpDir.appendingPathComponent(Self.wrappedInputFileName) + try wrapped.write(to: wrappedURL, atomically: true, encoding: .utf8) + + // Spawn `swift` on the wrapped file (with timeout watchdog). stdout is + // the rendered Swift source; stderr is compiler diagnostics, if any. + let raw = try await runSwift(wrappedPath: wrappedURL.path) + // #sourceLocation maps body diagnostics back to the input file. Errors in + // the preamble (lines outside the body) still reference the wrapper — + // rewrite literal occurrences of its path so users see something coherent. + let stderr = raw.stderr.replacingOccurrences( + of: wrappedURL.path, + with: absoluteInputPath + ) + + // Store on the way out. `try?` is deliberate: a cache write failure is + // not a render failure. The next run will simply miss and re-spawn. + if let cache, let cacheKey, raw.exitCode == 0 { + try? cache.store(key: cacheKey, data: raw.stdout) + } + + return ProcessResult(exitCode: raw.exitCode, stdout: raw.stdout, stderr: stderr) + } + + // MARK: - Spawning swift + + /// Spawns `swift` (via the injected `run` backend) on the wrapped input file. + /// When `timeoutSeconds > 0` the spawn races a sleep task in a throwing + /// task group; the loser is cancelled. On timeout, returns exit 124 with + /// a one-line stderr message — matching POSIX `timeout(1)`'s convention. + private func runSwift(wrappedPath: String) async throws -> ProcessResult { + let invocation = SwiftInvocation(libPath: libPath, wrappedPath: wrappedPath) + + // The actual backend call, wrapped in a closure so the task-group race + // below can hold a single Sendable reference to it. The backend already + // reports a `SwiftRunOutcome` (always `.completed` on a finished spawn). + let operation: @Sendable () async throws -> SwiftRunOutcome = { + try await self.run(invocation) + } + + // Race the invocation against a sleep watchdog; whichever finishes first + // wins, the other is cancelled. `timeoutSeconds <= 0` opts out of the + // race entirely (useful for debugging genuinely long codegen). + let outcome: SwiftRunOutcome + if timeoutSeconds <= 0 { + outcome = try await operation() + } else { + outcome = try await Task.timeout(seconds: timeoutSeconds, operation: operation) ?? .timedOut + } + + // Normalize both outcomes into a single ProcessResult shape. + switch outcome { + case .completed(let result): + return result + case .timedOut: + return ProcessResult( + exitCode: Self.timeoutExitCode, + stdout: Data(), + stderr: "skit: timed out after \(timeoutSeconds)s\n" + ) + } + } +} diff --git a/Sources/SyntaxKit/Execution/RunnerSetupError.swift b/Sources/SyntaxKit/Execution/RunnerSetupError.swift new file mode 100644 index 00000000..d8c09315 --- /dev/null +++ b/Sources/SyntaxKit/Execution/RunnerSetupError.swift @@ -0,0 +1,42 @@ +// +// RunnerSetupError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a render session couldn't be brought up. Decoupled from any caller: +/// the `Runner` session initializer reports *what* failed; the caller (CLI, +/// build plugin, in-process driver) decides how to present it and which exit +/// code to use. +public enum RunnerSetupError: Error { + /// The libSyntaxKit directory couldn't be resolved from the supplied + /// candidates or the bundle-relative fallbacks. Carries the underlying + /// `CLIError` describing the lookup. + case libResolutionFailed(any Error) + /// The bundle's recorded `swift --version` differs from the local one, + /// so spawning `swift` would hit a swiftmodule-version mismatch. + case toolchainMismatch(bundle: String, local: String) +} diff --git a/Sources/SyntaxKit/Execution/SingleFileRender.swift b/Sources/SyntaxKit/Execution/SingleFileRender.swift new file mode 100644 index 00000000..e6df4fe1 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SingleFileRender.swift @@ -0,0 +1,48 @@ +// +// SingleFileRender.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Result of `Runner.renderFile`. Both fields may be populated on success — +/// the spawned `swift` can emit warnings to `stderr` alongside a valid +/// `stdout`. The caller decides where the bytes go (file, stdout, in-memory). +public struct SingleFileRender: Sendable { + /// Rendered Swift source produced by the wrapped program. + public let stdout: Data + /// Compiler diagnostics from the spawned `swift`, with the wrapper path + /// rewritten back to the original input. Empty when the toolchain was silent. + public let stderr: String + + /// Creates a single-file render result from the rendered `stdout` bytes and + /// any `stderr` diagnostics. + public init(stdout: Data, stderr: String) { + self.stdout = stdout + self.stderr = stderr + } +} diff --git a/Sources/SyntaxKit/Execution/String+DylibFilename.swift b/Sources/SyntaxKit/Execution/String+DylibFilename.swift new file mode 100644 index 00000000..861440d4 --- /dev/null +++ b/Sources/SyntaxKit/Execution/String+DylibFilename.swift @@ -0,0 +1,51 @@ +// +// String+DylibFilename.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension String { + /// Shared-library filename prefix (`lib`). + private static let dylibPrefix = "lib" + #if os(Linux) + /// Linux shared-library filename extension. + private static let linuxDylibExtension = ".so" + #else + /// macOS shared-library filename extension. + private static let macOSDylibExtension = ".dylib" + #endif + + /// Treats `self` as a Swift library product name and returns the + /// platform-specific shared-library filename (`libFoo.dylib` on macOS, + /// `libFoo.so` on Linux). + internal var dylibFilename: String { + #if os(Linux) + return "\(Self.dylibPrefix)\(self)\(Self.linuxDylibExtension)" + #else + return "\(Self.dylibPrefix)\(self)\(Self.macOSDylibExtension)" + #endif + } +} diff --git a/Sources/SyntaxKit/Execution/SwiftBackend.swift b/Sources/SyntaxKit/Execution/SwiftBackend.swift new file mode 100644 index 00000000..f63c3eca --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftBackend.swift @@ -0,0 +1,49 @@ +// +// SwiftBackend.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The two `swift`-toolchain operations a render session needs from a spawn +/// backend: capture the local toolchain version (feeds the toolchain check and +/// the cache key), and render one `SwiftInvocation` by compiling it and running +/// the result. +/// +/// SyntaxKit defines the contract; a Subprocess-based conformance lives in the +/// `skit` CLI. `Sendable` so the spawn method can be captured in `Runner`'s +/// `@Sendable` run closure. +public protocol SwiftBackend: Sendable { + /// Verbatim `swift --version` output, or nil if the toolchain couldn't be + /// queried. + func captureSwiftVersion() async -> String? + + /// Renders `invocation` by compiling the wrapped program (with `swiftc`) and + /// running the produced binary, normalizing the result into a + /// `SwiftRunOutcome`. Named `runSwift` for the toolchain it drives, not the + /// `swift` interpreter — the interpreter's JIT can't load the dynamic + /// libSyntaxKit's symbols, so the conformer compiles instead. + func runSwift(for invocation: SwiftInvocation) async throws -> SwiftRunOutcome +} diff --git a/Sources/SyntaxKit/Execution/SwiftInvocation.swift b/Sources/SyntaxKit/Execution/SwiftInvocation.swift new file mode 100644 index 00000000..45562c69 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftInvocation.swift @@ -0,0 +1,40 @@ +// +// SwiftInvocation.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The inputs needed to invoke `swift` on one wrapped DSL program: the lib +/// directory (for `-I`/`-L`/`-rpath`) and the path to the wrapped source. +/// `Runner` hands this to its `run` closure, which performs the actual spawn — +/// the seam that keeps the engine free of any Subprocess dependency. +public struct SwiftInvocation: Sendable { + /// Directory holding `libSyntaxKit` + swiftmodules, used for the spawned + /// `swift`'s `-I`/`-L`/`-rpath` flags. + public let libPath: String + /// Path to the wrapped DSL source the spawned `swift` should compile and run. + public let wrappedPath: String +} diff --git a/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift b/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift new file mode 100644 index 00000000..a7fb2c50 --- /dev/null +++ b/Sources/SyntaxKit/Execution/SwiftRunOutcome.swift @@ -0,0 +1,36 @@ +// +// SwiftRunOutcome.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Either the spawned `swift` ran to completion (success or failure) or +/// the watchdog elapsed first. The completed payload is normalized to the +/// shape callers want regardless of platform. +public enum SwiftRunOutcome: Sendable { + case completed(ProcessResult) + case timedOut +} diff --git a/Sources/SyntaxKit/Execution/Task+Timeout.swift b/Sources/SyntaxKit/Execution/Task+Timeout.swift new file mode 100644 index 00000000..de27c77d --- /dev/null +++ b/Sources/SyntaxKit/Execution/Task+Timeout.swift @@ -0,0 +1,61 @@ +// +// Task+Timeout.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension Task where Failure == any Error, Success: Sendable { + /// Runs `operation`, returning its value, or `nil` if `seconds` elapses + /// first. The operation and a sleep watchdog race in a throwing task group; + /// whichever finishes first wins and the loser is cancelled. + /// + /// Takes seconds rather than a `Duration` so the engine stays buildable on + /// the package's minimum deployment targets — `Duration` requires iOS 16 / + /// tvOS 16 / watchOS 9, whereas `Task.sleep(nanoseconds:)` back-deploys. + public static func timeout( + seconds: Int, + operation: @escaping @Sendable () async throws -> Success + ) async throws -> Success? { + try await withThrowingTaskGroup(of: Success?.self) { group in + group.addTask { try await operation() } + group.addTask { + // Bare `Task` here means `Task`, which has no + // `sleep`; spell out the never-returning task to reach it. + try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + return nil + } + for try await first in group { + group.cancelAll() + return first + } + // Unreachable: two child tasks were added above, so the group always + // yields at least one result before draining. Trip loudly in debug if + // that invariant is ever broken; fall back to `nil` in release. + assertionFailure("task group drained without yielding a result") + return nil + } + } +} diff --git a/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift b/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift new file mode 100644 index 00000000..30e9fdb3 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ToolchainCheckResult.swift @@ -0,0 +1,73 @@ +// +// ToolchainCheckResult.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +/// Outcome of comparing the bundle's recorded build toolchain against the +/// local `swift --version`. The swiftmodule format isn't reliably +/// forward-compatible across Swift releases, so a mismatch is worth surfacing. +public enum ToolchainCheckResult { + /// Bundle stamp matches the local `swift --version` exactly. + case match + /// The toolchain check couldn't be performed: either `/swift-version.txt` + /// is missing (older bundle that predates the stamp) or the local + /// `swift --version` couldn't be captured. The caller proceeds; presentation + /// of the skipped-check note belongs to the call site, not this value type. + case stampMissing + /// The bundle stamp and the local `swift --version` differ. + case mismatch(bundle: String, local: String) + + /// Filename for the bundle's recorded build-toolchain version. + private static let toolchainStampFilename = "swift-version.txt" +} + +extension ToolchainCheckResult { + /// Compares `/swift-version.txt` to the caller-captured + /// `swiftVersion` string. The swiftmodule format isn't reliably + /// forward-compatible across even patch-level Swift releases (originating + /// bug: 6.3.0 → 6.3.2 rejected the swiftmodule), so the comparison is + /// exact-string after normalising trailing whitespace. + public init(libPath: String, swiftVersion: String?) { + let stampURL = URL(fileURLWithPath: libPath) + .appendingPathComponent(Self.toolchainStampFilename) + guard let stampData = try? Data(contentsOf: stampURL), + let stampRaw = String(data: stampData, encoding: .utf8) + else { + self = .stampMissing + return + } + guard let localRaw = swiftVersion else { + self = .stampMissing + return + } + let bundle = stampRaw.trimmingCharacters(in: .whitespacesAndNewlines) + let local = localRaw.trimmingCharacters(in: .whitespacesAndNewlines) + self = bundle == local ? .match : .mismatch(bundle: bundle, local: local) + } +} diff --git a/Sources/SyntaxKit/Execution/ToolchainVerification.swift b/Sources/SyntaxKit/Execution/ToolchainVerification.swift new file mode 100644 index 00000000..55df1fe9 --- /dev/null +++ b/Sources/SyntaxKit/Execution/ToolchainVerification.swift @@ -0,0 +1,45 @@ +// +// ToolchainVerification.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Outcome of the bundle/local Swift-toolchain compatibility check performed +/// when a render session was created. Stored on the `Runner` and carried on +/// `RunError.renderFailed` so a caller can hint that an *unverified* toolchain +/// may be the real cause of a build error — swiftmodules aren't reliably +/// compatible across compiler versions. A confirmed *mismatch* never reaches +/// here; it fails session setup via `RunnerSetupError.toolchainMismatch`. +public enum ToolchainVerification: Sendable, Equatable { + /// The bundle's recorded `swift --version` matched the local one. + case verified + /// The caller opted out of the check (`enforceToolchainCheck == false`). + case notChecked + /// The check ran but couldn't compare versions — the bundle had no + /// toolchain stamp, or the local `swift --version` couldn't be captured — + /// so compatibility is unknown. + case unverified +} diff --git a/Sources/SyntaxKit/Execution/URL+Reroot.swift b/Sources/SyntaxKit/Execution/URL+Reroot.swift new file mode 100644 index 00000000..d3075488 --- /dev/null +++ b/Sources/SyntaxKit/Execution/URL+Reroot.swift @@ -0,0 +1,46 @@ +// +// URL+Reroot.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +extension URL { + /// Re-roots this URL from `base` onto `newBase`, preserving the relative + /// subpath — e.g. `/in/sub/a.swift` re-rooted from `/in` onto `/out` becomes + /// `/out/sub/a.swift`. Used to mirror an input tree into an output tree. + /// + /// - Precondition: `self` is located under `base` (its path is prefixed by + /// `base`'s). Callers that enumerate `base` satisfy this by construction. + /// + /// `public` so a caller mirroring a rendered batch (e.g. the skit CLI) can + /// map each `FileOutcome.input` onto its destination tree. + public func rerooted(from base: URL, onto newBase: URL) -> URL { + let relative = path.dropFirst(base.path.count + 1) + return newBase.appendingPathComponent(String(relative)) + } +} diff --git a/Sources/SyntaxKit/Execution/WrappedSource.swift b/Sources/SyntaxKit/Execution/WrappedSource.swift new file mode 100644 index 00000000..f99dbb2b --- /dev/null +++ b/Sources/SyntaxKit/Execution/WrappedSource.swift @@ -0,0 +1,122 @@ +// +// WrappedSource.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SwiftParser +import SwiftSyntax + +/// A SyntaxKit DSL input split into hoisted `import` declarations and a verbatim +/// body, ready to be `rendered` into a complete Swift program that runs SyntaxKit +/// on the body. +/// +/// The body is fenced in `#sourceLocation` directives so compiler diagnostics in +/// the body reference the original input file and line numbers. +public struct WrappedSource { + /// The original input path, used for the `#sourceLocation` directive. + private let originalPath: String + /// Top-level `import` declarations hoisted above the wrapper body, each already + /// trimmed of surrounding whitespace. + private let hoistedImports: [String] + /// The input source from the first non-import byte onward, verbatim. + private let body: String + /// The 1-based line number the body starts on in the original file. + private let firstBodyLine: Int + + /// A complete Swift program that imports SyntaxKit, runs the body inside a + /// `Group { … }` builder, and prints the generated code. + public var rendered: String { + // Render the hoisted-imports block. Trailing newline only if non-empty so + // the wrapper doesn't grow an extra blank line in the common no-imports + // case. + let hoistedBlock = hoistedImports.isEmpty ? "" : hoistedImports.joined(separator: "\n") + "\n" + + // #sourceLocation must use a forward-slash path; escape backslashes/quotes + // defensively even though macOS paths shouldn't contain them. + let escapedPath = + originalPath + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + // Build the final wrapper. Layout: SyntaxKit import → hoisted imports → + // Group { #sourceLocation(...) #sourceLocation() } → print. + return """ + import SyntaxKit + \(hoistedBlock) + let __skit_root = Group { + #sourceLocation(file: "\(escapedPath)", line: \(firstBodyLine)) + \(body) + #sourceLocation() + } + + print(__skit_root.generateCode()) + """ + } + + /// Parses `source`, hoisting leading `import` declarations and capturing the + /// remaining body along with the line it begins on. Everything before the first + /// non-import statement that *is* an import gets hoisted; anything before that + /// which is *not* an import stays in the body (e.g. a leading `// comment`). + public init(source: String, originalPath: String) { + self.originalPath = originalPath + + // Parse the input with SwiftSyntax. The location converter is needed to + // map the body's starting byte offset back to a 1-based line number for + // the `#sourceLocation` directive. + let tree = Parser.parse(source: source) + let locConverter = SourceLocationConverter(fileName: originalPath, tree: tree) + + // Scan top-level statements for hoistable imports. + var hoisted: [String] = [] + var firstBodyByte: AbsolutePosition? + + for item in tree.statements { + if let importDecl = item.item.as(ImportDeclSyntax.self), + firstBodyByte == nil + { + hoisted.append(importDecl.description.trimmingCharacters(in: .whitespacesAndNewlines)) + continue + } + firstBodyByte = item.position + break + } + + self.hoistedImports = hoisted + + // Compute the body slice (source from the first non-import byte onward) + // and the 1-based line number it lives on in the original file. + if let firstBodyByte { + let start = source.utf8.index(source.utf8.startIndex, offsetBy: firstBodyByte.utf8Offset) + self.body = String(source[start...]) + self.firstBodyLine = locConverter.location(for: firstBodyByte).line + } else { + self.body = "" + self.firstBodyLine = 1 + } + } +} diff --git a/Sources/SyntaxKit/Expressions/Assignment.swift b/Sources/SyntaxKit/Expressions/Assignment.swift index 4cfb55cd..d1852cfc 100644 --- a/Sources/SyntaxKit/Expressions/Assignment.swift +++ b/Sources/SyntaxKit/Expressions/Assignment.swift @@ -34,6 +34,7 @@ public struct Assignment: CodeBlock { private let target: String private let valueExpr: ExprSyntax + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let left = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(target))) let right = valueExpr diff --git a/Sources/SyntaxKit/Expressions/CaptureInfo.swift b/Sources/SyntaxKit/Expressions/CaptureInfo.swift index 69f4847f..a8601a59 100644 --- a/Sources/SyntaxKit/Expressions/CaptureInfo.swift +++ b/Sources/SyntaxKit/Expressions/CaptureInfo.swift @@ -52,10 +52,8 @@ internal struct CaptureInfo { if let varExp = refExp.captureExpression as? VariableExp { self.name = .identifier(varExp.name) } else { + // TODO: Review fallback for non-VariableExp capture expression. self.name = .identifier("self") // fallback - #warning( - "TODO: Review fallback for non-VariableExp capture expression" - ) } } @@ -65,10 +63,8 @@ internal struct CaptureInfo { if let varExp = param.value as? VariableExp { self.name = .identifier(varExp.name) } else { + // TODO: Review fallback for non-VariableExp parameter value. self.name = .identifier("self") // fallback - #warning( - "TODO: Review fallback for non-VariableExp parameter value" - ) } } } diff --git a/Sources/SyntaxKit/Expressions/Closure.swift b/Sources/SyntaxKit/Expressions/Closure.swift index 8941f030..2ec36d80 100644 --- a/Sources/SyntaxKit/Expressions/Closure.swift +++ b/Sources/SyntaxKit/Expressions/Closure.swift @@ -161,8 +161,7 @@ public struct Closure: CodeBlock { } /// Creates a simple closure with only a body. - /// - Parameters: - /// - body: A ``CodeBlockBuilder`` that provides the body of the closure. + /// - Parameter body: A ``CodeBlockBuilder`` that provides the body of the closure. public init( @CodeBlockBuilderResult body: () throws -> [any CodeBlock] ) rethrows { @@ -175,8 +174,7 @@ public struct Closure: CodeBlock { } /// Creates a closure with just a CodeBlock array. - /// - Parameters: - /// - body: An array of CodeBlock elements that form the body of the closure. + /// - Parameter body: An array of CodeBlock elements that form the body of the closure. public init(body: [any CodeBlock]) { self.capture = [] self.parameters = [] @@ -200,6 +198,7 @@ public struct Closure: CodeBlock { ) } + /// Adds an attribute to the closure with optional arguments. public func attribute(_ attribute: String, arguments: [String] = []) -> Self { var copy = self copy.attributes.append(AttributeInfo(name: attribute, arguments: arguments)) diff --git a/Sources/SyntaxKit/Expressions/ClosureType.swift b/Sources/SyntaxKit/Expressions/ClosureType.swift index e2fb1779..fa9c7109 100644 --- a/Sources/SyntaxKit/Expressions/ClosureType.swift +++ b/Sources/SyntaxKit/Expressions/ClosureType.swift @@ -35,6 +35,7 @@ public struct ClosureType: CodeBlock, TypeRepresentable { private let returnType: String? private var attributes: [AttributeInfo] = [] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { // Build parameters let paramList = parameters.map { param in diff --git a/Sources/SyntaxKit/Expressions/ConditionalOp.swift b/Sources/SyntaxKit/Expressions/ConditionalOp.swift index d2c55899..c5051f4c 100644 --- a/Sources/SyntaxKit/Expressions/ConditionalOp.swift +++ b/Sources/SyntaxKit/Expressions/ConditionalOp.swift @@ -35,6 +35,7 @@ public struct ConditionalOp: CodeBlock { private let thenExpression: any CodeBlock private let elseExpression: any CodeBlock + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let conditionExpr = ExprSyntax( fromProtocol: condition.syntax.as(ExprSyntax.self) diff --git a/Sources/SyntaxKit/Expressions/FunctionCallExp.swift b/Sources/SyntaxKit/Expressions/FunctionCallExp.swift index 5f3a8782..4697f80b 100644 --- a/Sources/SyntaxKit/Expressions/FunctionCallExp.swift +++ b/Sources/SyntaxKit/Expressions/FunctionCallExp.swift @@ -102,8 +102,8 @@ internal struct FunctionCallExp: CodeBlock { calledExpression: ExprSyntax( MemberAccessExprSyntax( base: baseExpr, - dot: .periodToken(), - name: .identifier(methodName) + period: .periodToken(), + declName: DeclReferenceExprSyntax(baseName: .identifier(methodName)) ) ), leftParen: .leftParenToken(), diff --git a/Sources/SyntaxKit/Expressions/Infix+Comparison.swift b/Sources/SyntaxKit/Expressions/Infix+Comparison.swift index fa88f6a9..9edaaebc 100644 --- a/Sources/SyntaxKit/Expressions/Infix+Comparison.swift +++ b/Sources/SyntaxKit/Expressions/Infix+Comparison.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import SwiftSyntax +import SwiftSyntax // MARK: - Comparison Operators diff --git a/Sources/SyntaxKit/Expressions/Infix.swift b/Sources/SyntaxKit/Expressions/Infix.swift index 534c29c2..c1a0cc2c 100644 --- a/Sources/SyntaxKit/Expressions/Infix.swift +++ b/Sources/SyntaxKit/Expressions/Infix.swift @@ -51,9 +51,10 @@ public struct Infix: CodeBlock, ExprCodeBlock { case wrongOperandCount(expected: Int, got: Int) case nonExprCodeBlockOperand + /// A human-readable description of this error. public var description: String { switch self { - case let .wrongOperandCount(expected, got): + case .wrongOperandCount(let expected, let got): return "Infix expects exactly \(expected) operands, got \(got)." case .nonExprCodeBlockOperand: return "Infix operands must conform to ExprCodeBlock protocol" @@ -65,6 +66,7 @@ public struct Infix: CodeBlock, ExprCodeBlock { private let leftOperand: any ExprCodeBlock private let rightOperand: any ExprCodeBlock + /// The SwiftSyntax expression representation of this code block. public var exprSyntax: ExprSyntax { let left = leftOperand.exprSyntax let right = rightOperand.exprSyntax @@ -86,6 +88,7 @@ public struct Infix: CodeBlock, ExprCodeBlock { ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { exprSyntax } @@ -116,6 +119,9 @@ public struct Infix: CodeBlock, ExprCodeBlock { /// - Parameters: /// - operation: The operator symbol as it should appear in source (e.g. "+", "-", "&&"). /// - content: A ``CodeBlockBuilder`` that supplies exactly two operand expressions. + /// - Throws: ``InfixError/wrongOperandCount`` if `content` returns a number of operands + /// other than two, or ``InfixError/nonExprCodeBlockOperand`` if any operand does not + /// conform to ``ExprCodeBlock``. /// /// Exactly two operands must be supplied – a left-hand side and a right-hand side. /// Each operand must conform to ExprCodeBlock. diff --git a/Sources/SyntaxKit/Expressions/Literal+ExprCodeBlock.swift b/Sources/SyntaxKit/Expressions/Literal+ExprCodeBlock.swift index be54e85a..955414f5 100644 --- a/Sources/SyntaxKit/Expressions/Literal+ExprCodeBlock.swift +++ b/Sources/SyntaxKit/Expressions/Literal+ExprCodeBlock.swift @@ -50,7 +50,7 @@ extension Literal: ExprCodeBlock { case .float(let value): return ExprSyntax(FloatLiteralExprSyntax(literal: .floatLiteral(String(value)))) case .integer(let value): - return ExprSyntax(IntegerLiteralExprSyntax(digits: .integerLiteral(String(value)))) + return ExprSyntax(IntegerLiteralExprSyntax(literal: .integerLiteral(String(value)))) case .nil: return ExprSyntax(NilLiteralExprSyntax(nilKeyword: .keyword(.nil))) case .boolean(let value): @@ -62,7 +62,7 @@ extension Literal: ExprCodeBlock { case .ref(let value): return ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(value))) case .tuple(let elements): - let tupleElements = TupleExprElementListSyntax( + let tupleElements = LabeledExprListSyntax( elements.enumerated().map { index, element in let elementExpr: ExprSyntax if let element = element { @@ -71,7 +71,7 @@ extension Literal: ExprCodeBlock { // Wildcard pattern - use underscore elementExpr = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("_"))) } - return TupleExprElementSyntax( + return LabeledExprSyntax( label: nil, colon: nil, expression: elementExpr, @@ -123,9 +123,9 @@ extension Literal: ExprCodeBlock { elements.enumerated().map { index, keyValue in let (key, value) = keyValue return DictionaryElementSyntax( - keyExpression: key.exprSyntax, + key: key.exprSyntax, colon: .colonToken(), - valueExpression: value.exprSyntax, + value: value.exprSyntax, trailingComma: index < elements.count - 1 ? .commaToken(trailingTrivia: .space) : nil diff --git a/Sources/SyntaxKit/Expressions/Literal.swift b/Sources/SyntaxKit/Expressions/Literal.swift index 1b5ddb1d..7d16d19b 100644 --- a/Sources/SyntaxKit/Expressions/Literal.swift +++ b/Sources/SyntaxKit/Expressions/Literal.swift @@ -119,7 +119,7 @@ public enum Literal: CodeBlock, CodeBlockable, Sendable { return FloatLiteralExprSyntax(literal: .floatLiteral(String(value))) case .integer(let value): - return IntegerLiteralExprSyntax(digits: .integerLiteral(String(value))) + return IntegerLiteralExprSyntax(literal: .integerLiteral(String(value))) case .nil: return NilLiteralExprSyntax(nilKeyword: .keyword(.nil)) case .boolean(let value): @@ -127,7 +127,7 @@ public enum Literal: CodeBlock, CodeBlockable, Sendable { case .ref(let value): return DeclReferenceExprSyntax(baseName: .identifier(value)) case .tuple(let elements): - let tupleElements = TupleExprElementListSyntax( + let tupleElements = LabeledExprListSyntax( elements.enumerated().map { index, element in let elementExpr: ExprSyntax if let element = element { @@ -138,7 +138,7 @@ public enum Literal: CodeBlock, CodeBlockable, Sendable { // Wildcard pattern - use underscore elementExpr = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("_"))) } - return TupleExprElementSyntax( + return LabeledExprSyntax( label: nil, colon: nil, expression: elementExpr, @@ -175,10 +175,10 @@ public enum Literal: CodeBlock, CodeBlockable, Sendable { elements.enumerated().map { index, keyValue in let (key, value) = keyValue return DictionaryElementSyntax( - keyExpression: key.syntax.as(ExprSyntax.self) + key: key.syntax.as(ExprSyntax.self) ?? ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))), colon: .colonToken(), - valueExpression: value.syntax.as(ExprSyntax.self) + value: value.syntax.as(ExprSyntax.self) ?? ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))), trailingComma: index < elements.count - 1 ? .commaToken(trailingTrivia: .space) : nil ) diff --git a/Sources/SyntaxKit/Expressions/OptionalChainingExp.swift b/Sources/SyntaxKit/Expressions/OptionalChainingExp.swift index c287679b..6208487b 100644 --- a/Sources/SyntaxKit/Expressions/OptionalChainingExp.swift +++ b/Sources/SyntaxKit/Expressions/OptionalChainingExp.swift @@ -39,10 +39,8 @@ internal struct OptionalChainingExp: CodeBlock { if let expr = base.syntax.as(ExprSyntax.self) { baseExpr = expr } else { - // Fallback to a default expression if conversion fails - #warning( - "TODO: Review fallback for failed expression conversion" - ) + // TODO: Review fallback for failed expression conversion. + // Fallback to a default expression if conversion fails. baseExpr = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(""))) } return OptionalChainingExprSyntax(expression: baseExpr) diff --git a/Sources/SyntaxKit/Expressions/PropertyAccessExp.swift b/Sources/SyntaxKit/Expressions/PropertyAccessExp.swift index 86dced06..a9d51d09 100644 --- a/Sources/SyntaxKit/Expressions/PropertyAccessExp.swift +++ b/Sources/SyntaxKit/Expressions/PropertyAccessExp.swift @@ -40,12 +40,11 @@ internal struct PropertyAccessExp: CodeBlock, ExprCodeBlock, PropertyAccessible ?? ExprSyntax( DeclReferenceExprSyntax(baseName: .identifier("")) ) - let property = TokenSyntax.identifier(propertyName) return ExprSyntax( MemberAccessExprSyntax( base: baseSyntax, - dot: .periodToken(), - name: property + period: .periodToken(), + declName: DeclReferenceExprSyntax(baseName: .identifier(propertyName)) ) ) } diff --git a/Sources/SyntaxKit/Expressions/Return.swift b/Sources/SyntaxKit/Expressions/Return.swift index 8ccbbf07..ce7786be 100644 --- a/Sources/SyntaxKit/Expressions/Return.swift +++ b/Sources/SyntaxKit/Expressions/Return.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Return: CodeBlock { private let exprs: [any CodeBlock] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { if let expr = exprs.first { if let varExp = expr as? VariableExp { @@ -49,10 +50,8 @@ public struct Return: CodeBlock { } else if let syntax = expr.syntax.as(ExprSyntax.self) { exprSyntax = syntax } else { + // TODO: Review fallback for no valid expression - consider if this should be an error instead. // fallback: no valid expression - #warning( - "TODO: Review fallback for no valid expression - consider if this should be an error instead" - ) return ReturnStmtSyntax( returnKeyword: .keyword(.return, trailingTrivia: .space) ) diff --git a/Sources/SyntaxKit/Functions/Function+EffectSpecifiers.swift b/Sources/SyntaxKit/Functions/Function+EffectSpecifiers.swift index da188940..8be6c185 100644 --- a/Sources/SyntaxKit/Functions/Function+EffectSpecifiers.swift +++ b/Sources/SyntaxKit/Functions/Function+EffectSpecifiers.swift @@ -35,37 +35,23 @@ extension Function { switch effect { case .none: return nil - case let .throws(isRethrows, errorType): + case .throws(let isRethrows, let errorType): let throwsSpecifier = buildThrowsSpecifier(isRethrows: isRethrows) - if let errorType = errorType { - return FunctionEffectSpecifiersSyntax( - asyncSpecifier: nil, - throwsClause: buildThrowsClause(throwsSpecifier: throwsSpecifier, errorType: errorType) - ) - } else { - return FunctionEffectSpecifiersSyntax( - asyncSpecifier: nil, - throwsSpecifier: throwsSpecifier - ) - } + return FunctionEffectSpecifiersSyntax( + asyncSpecifier: nil, + throwsClause: buildThrowsClause(throwsSpecifier: throwsSpecifier, errorType: errorType) + ) case .async: return FunctionEffectSpecifiersSyntax( asyncSpecifier: .keyword(.async, leadingTrivia: .space, trailingTrivia: .space), - throwsSpecifier: nil + throwsClause: nil ) - case let .asyncThrows(isRethrows, errorType): + case .asyncThrows(let isRethrows, let errorType): let throwsSpecifier = buildThrowsSpecifier(isRethrows: isRethrows) - if let errorType = errorType { - return FunctionEffectSpecifiersSyntax( - asyncSpecifier: .keyword(.async, leadingTrivia: .space, trailingTrivia: .space), - throwsClause: buildThrowsClause(throwsSpecifier: throwsSpecifier, errorType: errorType) - ) - } else { - return FunctionEffectSpecifiersSyntax( - asyncSpecifier: .keyword(.async, leadingTrivia: .space, trailingTrivia: .space), - throwsSpecifier: throwsSpecifier - ) - } + return FunctionEffectSpecifiersSyntax( + asyncSpecifier: .keyword(.async, leadingTrivia: .space, trailingTrivia: .space), + throwsClause: buildThrowsClause(throwsSpecifier: throwsSpecifier, errorType: errorType) + ) } } @@ -74,15 +60,19 @@ extension Function { .keyword(isRethrows ? .rethrows : .throws, leadingTrivia: .space) } - /// Builds the throws clause with error type. - private func buildThrowsClause(throwsSpecifier: TokenSyntax, errorType: String) + /// Builds the throws clause, optionally with a typed error. + private func buildThrowsClause(throwsSpecifier: TokenSyntax, errorType: String?) -> ThrowsClauseSyntax { - ThrowsClauseSyntax( - throwsSpecifier: throwsSpecifier, - leftParen: .leftParenToken(), - type: IdentifierTypeSyntax(name: .identifier(errorType)), - rightParen: .rightParenToken() - ) + if let errorType { + ThrowsClauseSyntax( + throwsSpecifier: throwsSpecifier, + leftParen: .leftParenToken(), + type: IdentifierTypeSyntax(name: .identifier(errorType)), + rightParen: .rightParenToken() + ) + } else { + ThrowsClauseSyntax(throwsSpecifier: throwsSpecifier) + } } } diff --git a/Sources/SyntaxKit/Functions/Function+Effects.swift b/Sources/SyntaxKit/Functions/Function+Effects.swift index 7765f4c6..b0505647 100644 --- a/Sources/SyntaxKit/Functions/Function+Effects.swift +++ b/Sources/SyntaxKit/Functions/Function+Effects.swift @@ -42,6 +42,7 @@ extension Function { /// Marks the function as `throws` or `rethrows`. /// - Parameter isRethrows: Pass `true` to emit `rethrows` instead of `throws`. + /// - Returns: A copy of the function with the throws effect applied. public func `throws`(isRethrows: Bool = false) -> Self { var copy = self switch effect { @@ -55,6 +56,7 @@ extension Function { /// Marks the function as `throws` with a specific error type. /// - Parameter errorType: The error type to specify in the throws clause. + /// - Returns: A copy of the function with the typed throws effect applied. public func `throws`(_ errorType: String) -> Self { var copy = self switch effect { @@ -75,6 +77,7 @@ extension Function { /// Marks the function as `async throws` or `async rethrows`. /// - Parameter isRethrows: Pass `true` to emit `async rethrows`. + /// - Returns: A copy of the function with the async-throws effect applied. public func asyncThrows(isRethrows: Bool = false) -> Self { var copy = self copy.effect = .asyncThrows(isRethrows: isRethrows, errorType: nil) @@ -83,6 +86,7 @@ extension Function { /// Marks the function as `async throws` with a specific error type. /// - Parameter errorType: The error type to specify in the throws clause. + /// - Returns: A copy of the function with the typed async-throws effect applied. public func asyncThrows(_ errorType: String) -> Self { var copy = self copy.effect = .asyncThrows(isRethrows: false, errorType: errorType) diff --git a/Sources/SyntaxKit/Functions/Function+Modifiers.swift b/Sources/SyntaxKit/Functions/Function+Modifiers.swift index f50e427a..2aa4c2a0 100644 --- a/Sources/SyntaxKit/Functions/Function+Modifiers.swift +++ b/Sources/SyntaxKit/Functions/Function+Modifiers.swift @@ -46,6 +46,21 @@ extension Function { return copy } + /// Sets the access modifier for the function declaration. + /// - Parameter access: The access modifier. + /// - Returns: A copy of the function with the access modifier set. + public func access(_ access: AccessModifier) -> Self { + var copy = self + copy.accessModifier = access + return copy + } + + /// Marks the function as `throws` (alias for `.throws()` that avoids keyword escaping). + /// - Returns: A copy of the function marked as `throws`. + public func throwing() -> Self { + `throws`() + } + /// Adds an attribute to the function declaration. /// - Parameters: /// - attribute: The attribute name (without the @ symbol). diff --git a/Sources/SyntaxKit/Functions/Function+Syntax.swift b/Sources/SyntaxKit/Functions/Function+Syntax.swift index 9a7d652a..81f7813a 100644 --- a/Sources/SyntaxKit/Functions/Function+Syntax.swift +++ b/Sources/SyntaxKit/Functions/Function+Syntax.swift @@ -83,19 +83,22 @@ extension Function { // Build modifiers var modifiers: DeclModifierListSyntax = [] + if let access = accessModifier { + modifiers = DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(access.keyword, trailingTrivia: .space)) + ]) + } if isStatic { modifiers = DeclModifierListSyntax( - [ + modifiers + [ DeclModifierSyntax(name: .keyword(.static, trailingTrivia: .space)) ] ) } if isMutating { - modifiers = DeclModifierListSyntax( - modifiers + [ - DeclModifierSyntax(name: .keyword(.mutating, trailingTrivia: .space)) - ] - ) + modifiers += [ + DeclModifierSyntax(name: .keyword(.mutating, trailingTrivia: .space)) + ] } return FunctionDeclSyntax( @@ -135,13 +138,13 @@ extension Function { rightParen = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } argumentsSyntax = .argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with( \.trailingComma, @@ -162,6 +165,7 @@ extension Function { arguments: argumentsSyntax, rightParen: rightParen ) + .with(\.trailingTrivia, .newline) ) } diff --git a/Sources/SyntaxKit/Functions/Function.swift b/Sources/SyntaxKit/Functions/Function.swift index dabae9f7..9c9100b3 100644 --- a/Sources/SyntaxKit/Functions/Function.swift +++ b/Sources/SyntaxKit/Functions/Function.swift @@ -39,6 +39,7 @@ public struct Function: CodeBlock { internal var isMutating: Bool = false internal var effect: Effect = .none internal var attributes: [AttributeInfo] = [] + internal var accessModifier: AccessModifier? /// Creates a `func` declaration. /// - Parameters: diff --git a/Sources/SyntaxKit/Functions/FunctionRequirement.swift b/Sources/SyntaxKit/Functions/FunctionRequirement.swift index 341dd80c..1f7dd7dd 100644 --- a/Sources/SyntaxKit/Functions/FunctionRequirement.swift +++ b/Sources/SyntaxKit/Functions/FunctionRequirement.swift @@ -37,6 +37,7 @@ public struct FunctionRequirement: CodeBlock { private var isStatic: Bool = false private var isMutating: Bool = false + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let funcKeyword = TokenSyntax.keyword(.func, trailingTrivia: .space) let identifier = TokenSyntax.identifier(name) @@ -89,9 +90,9 @@ public struct FunctionRequirement: CodeBlock { ]) } if isMutating { - modifiers = DeclModifierListSyntax( - modifiers + [DeclModifierSyntax(name: .keyword(.mutating, trailingTrivia: .space))] - ) + modifiers += [ + DeclModifierSyntax(name: .keyword(.mutating, trailingTrivia: .space)) + ] } return FunctionDeclSyntax( diff --git a/Sources/SyntaxKit/Parameters/Parameter.swift b/Sources/SyntaxKit/Parameters/Parameter.swift index 1b154751..3a5f81a7 100644 --- a/Sources/SyntaxKit/Parameters/Parameter.swift +++ b/Sources/SyntaxKit/Parameters/Parameter.swift @@ -49,6 +49,7 @@ public struct Parameter: CodeBlock { /// Convenience flag – true when the parameter uses the underscore label. internal var isUnnamed: Bool { label == "_" } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let callLabel = label ?? name diff --git a/Sources/SyntaxKit/Parameters/ParameterExp.swift b/Sources/SyntaxKit/Parameters/ParameterExp.swift index 8cdf23c8..8903bf18 100644 --- a/Sources/SyntaxKit/Parameters/ParameterExp.swift +++ b/Sources/SyntaxKit/Parameters/ParameterExp.swift @@ -34,6 +34,7 @@ public struct ParameterExp: CodeBlock { internal let name: String internal let value: any CodeBlock + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { if name.isEmpty { if let exprBlock = value as? any ExprCodeBlock { diff --git a/Sources/SyntaxKit/Patterns/LetBindingPattern.swift b/Sources/SyntaxKit/Patterns/LetBindingPattern.swift index f7da63c5..fcad1d6c 100644 --- a/Sources/SyntaxKit/Patterns/LetBindingPattern.swift +++ b/Sources/SyntaxKit/Patterns/LetBindingPattern.swift @@ -29,18 +29,6 @@ import SwiftSyntax -// MARK: - Let binding pattern - -/// Namespace for pattern creation utilities. -public enum Pattern { - /// Creates a `let` binding pattern for switch cases. - /// - Parameter identifier: The name of the variable to bind. - /// - Returns: A pattern that binds the value to the given identifier. - public static func `let`(_ identifier: String) -> any PatternConvertible { - LetBindingPattern(identifier: identifier) - } -} - /// A `let` binding pattern for switch cases. internal struct LetBindingPattern: PatternConvertible { private let identifier: String diff --git a/Sources/SyntaxKit/Patterns/Pattern.swift b/Sources/SyntaxKit/Patterns/Pattern.swift new file mode 100644 index 00000000..d6392b95 --- /dev/null +++ b/Sources/SyntaxKit/Patterns/Pattern.swift @@ -0,0 +1,38 @@ +// +// Pattern.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. +// + +/// Namespace for pattern creation utilities. +public enum Pattern { + /// Creates a `let` binding pattern for switch cases. + /// - Parameter identifier: The name of the variable to bind. + /// - Returns: A pattern that binds the value to the given identifier. + public static func `let`(_ identifier: String) -> any PatternConvertible { + LetBindingPattern(identifier: identifier) + } +} diff --git a/Sources/SyntaxKit/Utilities/Break.swift b/Sources/SyntaxKit/Utilities/Break.swift index cf1e33ed..3420b70e 100644 --- a/Sources/SyntaxKit/Utilities/Break.swift +++ b/Sources/SyntaxKit/Utilities/Break.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Break: CodeBlock { private let label: String? + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let breakStmt = BreakStmtSyntax( breakKeyword: .keyword(.break, trailingTrivia: .newline) diff --git a/Sources/SyntaxKit/Utilities/Case.swift b/Sources/SyntaxKit/Utilities/Case.swift index b403ded9..283ca989 100644 --- a/Sources/SyntaxKit/Utilities/Case.swift +++ b/Sources/SyntaxKit/Utilities/Case.swift @@ -37,6 +37,7 @@ public struct Case: CodeBlock { private let enumCaseName: String? private var associatedValue: (name: String, type: String)? + /// The SwiftSyntax representation of this switch case. public var switchCaseSyntax: SwitchCaseSyntax { let caseItems = SwitchCaseItemListSyntax( patterns.enumerated().map { index, pat in @@ -72,6 +73,7 @@ public struct Case: CodeBlock { ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { if isEnumCase { // Handle enum case declaration diff --git a/Sources/SyntaxKit/Utilities/Continue.swift b/Sources/SyntaxKit/Utilities/Continue.swift index 4f1fe599..fcb520ab 100644 --- a/Sources/SyntaxKit/Utilities/Continue.swift +++ b/Sources/SyntaxKit/Utilities/Continue.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Continue: CodeBlock { private let label: String? + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let continueStmt = ContinueStmtSyntax( continueKeyword: .keyword(.continue, trailingTrivia: .newline) diff --git a/Sources/SyntaxKit/Utilities/Default.swift b/Sources/SyntaxKit/Utilities/Default.swift index fc7b04cb..b49a1c10 100644 --- a/Sources/SyntaxKit/Utilities/Default.swift +++ b/Sources/SyntaxKit/Utilities/Default.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Default: CodeBlock { private let body: [any CodeBlock] + /// The SwiftSyntax representation of this switch case. public var switchCaseSyntax: SwitchCaseSyntax { let statements = CodeBlockItemListSyntax( body.compactMap { @@ -56,6 +57,7 @@ public struct Default: CodeBlock { statements: statements ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { switchCaseSyntax } /// Creates a default case declaration. diff --git a/Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift b/Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift new file mode 100644 index 00000000..1f3cf0af --- /dev/null +++ b/Sources/SyntaxKit/Utilities/ExprSyntax+AttributeArgument.swift @@ -0,0 +1,54 @@ +// +// ExprSyntax+AttributeArgument.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 SwiftSyntax + +extension ExprSyntax { + /// Creates an expression from an attribute-argument string. + /// + /// A double-quoted value becomes a string literal expression; anything else + /// becomes an identifier reference expression. + /// - Parameter argument: The raw attribute-argument text. + internal init(attributeArgument argument: String) { + if argument.hasPrefix("\"") && argument.hasSuffix("\"") && argument.count >= 2 { + let content = String(argument.dropFirst().dropLast()) + self = ExprSyntax( + StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: StringLiteralSegmentListSyntax([ + .stringSegment(StringSegmentSyntax(content: .stringSegment(content))) + ]), + closingQuote: .stringQuoteToken() + ) + ) + } else { + self = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(argument))) + } + } +} diff --git a/Sources/SyntaxKit/Utilities/Fallthrough.swift b/Sources/SyntaxKit/Utilities/Fallthrough.swift index ce2d891c..85de9132 100644 --- a/Sources/SyntaxKit/Utilities/Fallthrough.swift +++ b/Sources/SyntaxKit/Utilities/Fallthrough.swift @@ -31,6 +31,7 @@ public import SwiftSyntax /// A `fallthrough` statement. public struct Fallthrough: CodeBlock { + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { StmtSyntax( FallThroughStmtSyntax( diff --git a/Sources/SyntaxKit/Utilities/Group.swift b/Sources/SyntaxKit/Utilities/Group.swift index 58a7187f..5623917d 100644 --- a/Sources/SyntaxKit/Utilities/Group.swift +++ b/Sources/SyntaxKit/Utilities/Group.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Group: CodeBlock { internal let members: [any CodeBlock] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let statements = members.flatMap { block -> [CodeBlockItemSyntax] in if let list = block.syntax.as(CodeBlockItemListSyntax.self) { @@ -47,11 +48,9 @@ public struct Group: CodeBlock { } else if let expr = block.syntax.as(ExprSyntax.self) { item = .expr(expr) } else { - // Skip unsupported syntax types instead of crashing - // This allows the group to continue processing other valid blocks - #warning( - "TODO: Review fallback for unsupported syntax types - consider if this should be an error instead" - ) + // TODO: Review fallback for unsupported syntax types - consider if this should be an error instead. + // Skip unsupported syntax types instead of crashing so the group can continue + // processing other valid blocks. return [] } return [CodeBlockItemSyntax(item: item, trailingTrivia: .newline)] diff --git a/Sources/SyntaxKit/Utilities/Let.swift b/Sources/SyntaxKit/Utilities/Let.swift index 76000263..5bf653b7 100644 --- a/Sources/SyntaxKit/Utilities/Let.swift +++ b/Sources/SyntaxKit/Utilities/Let.swift @@ -34,6 +34,7 @@ public struct Let: CodeBlock { internal let name: String internal let value: any CodeBlock + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { CodeBlockItemSyntax( item: .decl( diff --git a/Sources/SyntaxKit/Utilities/Parenthesized.swift b/Sources/SyntaxKit/Utilities/Parenthesized.swift index 068c9d32..a65882b1 100644 --- a/Sources/SyntaxKit/Utilities/Parenthesized.swift +++ b/Sources/SyntaxKit/Utilities/Parenthesized.swift @@ -33,6 +33,7 @@ public import SwiftSyntax public struct Parenthesized: CodeBlock, ExprCodeBlock { private let content: any CodeBlock + /// The SwiftSyntax expression representation of this code block. public var exprSyntax: ExprSyntax { ExprSyntax( TupleExprSyntax( @@ -45,6 +46,7 @@ public struct Parenthesized: CodeBlock, ExprCodeBlock { ) } + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { exprSyntax } diff --git a/Sources/SyntaxKit/Utilities/PropertyRequirement.swift b/Sources/SyntaxKit/Utilities/PropertyRequirement.swift index 5620b9eb..3fd9a6ad 100644 --- a/Sources/SyntaxKit/Utilities/PropertyRequirement.swift +++ b/Sources/SyntaxKit/Utilities/PropertyRequirement.swift @@ -41,6 +41,7 @@ public struct PropertyRequirement: CodeBlock { private let type: String private let access: Access + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let varKeyword = TokenSyntax.keyword(.var, trailingTrivia: .space) let identifier = TokenSyntax.identifier(name, trailingTrivia: .space) diff --git a/Sources/SyntaxKit/Utilities/Then.swift b/Sources/SyntaxKit/Utilities/Then.swift index ea85cee7..5909f399 100644 --- a/Sources/SyntaxKit/Utilities/Then.swift +++ b/Sources/SyntaxKit/Utilities/Then.swift @@ -46,6 +46,7 @@ public struct Then: CodeBlock { /// The statements that make up the `else` body. public let body: [any CodeBlock] + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let statements = CodeBlockItemListSyntax( body.compactMap { element in diff --git a/Sources/SyntaxKit/Variables/ComputedProperty.swift b/Sources/SyntaxKit/Variables/ComputedProperty.swift index a9de0069..7795f12f 100644 --- a/Sources/SyntaxKit/Variables/ComputedProperty.swift +++ b/Sources/SyntaxKit/Variables/ComputedProperty.swift @@ -37,6 +37,7 @@ public struct ComputedProperty: CodeBlock { private var accessModifier: AccessModifier? private let explicitType: Bool + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { let accessor = AccessorBlockSyntax( leftBrace: TokenSyntax.leftBraceToken(leadingTrivia: .space, trailingTrivia: .newline), diff --git a/Sources/SyntaxKit/Variables/Variable+Attributes.swift b/Sources/SyntaxKit/Variables/Variable+Attributes.swift index 6f4f9541..b35f39b5 100644 --- a/Sources/SyntaxKit/Variables/Variable+Attributes.swift +++ b/Sources/SyntaxKit/Variables/Variable+Attributes.swift @@ -54,6 +54,7 @@ extension Variable { arguments: attributeArgs.arguments, rightParen: attributeArgs.rightParen ) + .with(\.trailingTrivia, .space) ) } @@ -67,13 +68,13 @@ extension Variable { let rightParen: TokenSyntax = .rightParenToken() let argumentList = arguments.map { argument in - DeclReferenceExprSyntax(baseName: .identifier(argument)) + ExprSyntax(attributeArgument: argument) } let argumentsSyntax = AttributeSyntax.Arguments.argumentList( LabeledExprListSyntax( argumentList.enumerated().map { index, expr in - var element = LabeledExprSyntax(expression: ExprSyntax(expr)) + var element = LabeledExprSyntax(expression: expr) if index < argumentList.count - 1 { element = element.with(\.trailingComma, .commaToken(trailingTrivia: .space)) } diff --git a/Sources/SyntaxKit/Variables/Variable+LiteralInitializers.swift b/Sources/SyntaxKit/Variables/Variable+LiteralInitializers.swift index 5769bacf..1325ea28 100644 --- a/Sources/SyntaxKit/Variables/Variable+LiteralInitializers.swift +++ b/Sources/SyntaxKit/Variables/Variable+LiteralInitializers.swift @@ -144,6 +144,7 @@ extension Variable { _ kind: VariableKind, name: String, @CodeBlockBuilderResult value: () throws -> [any CodeBlock], + // swiftlint:disable:next discouraged_optional_boolean explicitType: Bool? = nil ) rethrows { self.init( diff --git a/Sources/SyntaxKit/Variables/VariableExp.swift b/Sources/SyntaxKit/Variables/VariableExp.swift index 70e55e9e..f4b61e61 100644 --- a/Sources/SyntaxKit/Variables/VariableExp.swift +++ b/Sources/SyntaxKit/Variables/VariableExp.swift @@ -33,14 +33,17 @@ public import SwiftSyntax public struct VariableExp: CodeBlock, PatternConvertible, ExprCodeBlock { internal let name: String + /// The SwiftSyntax representation of this code block. public var syntax: any SyntaxProtocol { ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(name))) } + /// The SwiftSyntax expression representation of this code block. public var exprSyntax: ExprSyntax { ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(name))) } + /// The SwiftSyntax pattern representation of this code block. public var patternSyntax: PatternSyntax { PatternSyntax(IdentifierPatternSyntax(identifier: .identifier(name))) } diff --git a/Sources/SyntaxParser/SyntaxParser.swift b/Sources/SyntaxParser/SyntaxParser.swift index be15e1e6..67417f15 100644 --- a/Sources/SyntaxParser/SyntaxParser.swift +++ b/Sources/SyntaxParser/SyntaxParser.swift @@ -49,6 +49,7 @@ import TokenVisitor package enum SyntaxParser { // MARK: - Configuration Constants + // periphery:ignore - retained until deprecated parse(code:options:) is removed (see #149) /// Option key to enable operator precedence folding during parsing. /// When enabled, expressions are reorganized according to Swift's operator precedence rules. @available( @@ -57,6 +58,7 @@ package enum SyntaxParser { ) private static let fold = "fold" + // periphery:ignore - retained until deprecated parse(code:options:) is removed (see #149) /// Option key to include missing/implicit tokens in the output. /// Useful for debugging or when you need to see all syntax elements including placeholders. @available( @@ -82,6 +84,7 @@ package enum SyntaxParser { return TreeNode.parseTree(from: sourceFile) } + // periphery:ignore - scheduled for removal (see #149); kept for source compatibility /// Parses Swift source code and returns a JSON representation of its syntax tree. /// /// This method performs the complete parsing pipeline: diff --git a/Sources/SyntaxParser/SyntaxResponse.swift b/Sources/SyntaxParser/SyntaxResponse.swift index 03bd0c35..0c63b831 100644 --- a/Sources/SyntaxParser/SyntaxResponse.swift +++ b/Sources/SyntaxParser/SyntaxResponse.swift @@ -29,6 +29,7 @@ import Foundation +// periphery:ignore - scheduled for removal (see #149); preserved for source compatibility /// Container for the final JSON representation of parsed Swift syntax. /// /// SyntaxResponse is the top-level result type returned by the SyntaxParser. diff --git a/Sources/TokenVisitor/String.swift b/Sources/TokenVisitor/String.swift index 0596f1d1..fd125313 100644 --- a/Sources/TokenVisitor/String.swift +++ b/Sources/TokenVisitor/String.swift @@ -28,5 +28,6 @@ // extension String { + // periphery:ignore - utility constant for downstream consumers internal static let empty = "" } diff --git a/Sources/TokenVisitor/StructureProperty.swift b/Sources/TokenVisitor/StructureProperty.swift index 9c0e7223..cf5246e0 100644 --- a/Sources/TokenVisitor/StructureProperty.swift +++ b/Sources/TokenVisitor/StructureProperty.swift @@ -83,9 +83,7 @@ package struct StructureProperty: Codable, Equatable, Sendable { /// Creates a StructureProperty for a missing property with a nil value indicator. /// - /// - Parameters: - /// - name: The property name - /// - nilValue: The value to display for the nil value (will be converted to string) + /// - Parameter name: The property name. The value will be displayed as the nil-value placeholder. internal init(nilValueWithName name: String) { self.init(name: name, value: StructureValue(text: Self.nilValue), ref: nil) } diff --git a/Sources/TokenVisitor/TreeNodeProtocol+Extensions.swift b/Sources/TokenVisitor/TreeNodeProtocol+Extensions.swift index 1a4c9262..a8e15ba0 100644 --- a/Sources/TokenVisitor/TreeNodeProtocol+Extensions.swift +++ b/Sources/TokenVisitor/TreeNodeProtocol+Extensions.swift @@ -30,6 +30,7 @@ package import SwiftSyntax extension TreeNodeProtocol { + // periphery:ignore:parameters showingMissingTokens - parameter currently ignored (see #152) package static func parseTree( from sourceFile: SourceFileSyntax, withFileName fileName: String = .defaultFileName, diff --git a/Sources/skit/ProcessInfo+SkitLibPath.swift b/Sources/skit/ProcessInfo+SkitLibPath.swift new file mode 100644 index 00000000..c8beb1e1 --- /dev/null +++ b/Sources/skit/ProcessInfo+SkitLibPath.swift @@ -0,0 +1,41 @@ +// +// ProcessInfo+SkitLibPath.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +extension ProcessInfo { + /// Environment variable holding an override for the libSyntaxKit directory. + internal static let skitLibDirEnvironmentKey = "SKIT_LIB_DIR" + + /// The libSyntaxKit directory specified via `SKIT_LIB_DIR`, or `nil` when + /// the env var is unset or empty. + internal var skitLibPath: String? { + environment[Self.skitLibDirEnvironmentKey].flatMap { $0.isEmpty ? nil : $0 } + } +} diff --git a/Sources/skit/README.md b/Sources/skit/README.md new file mode 100644 index 00000000..23d5c518 --- /dev/null +++ b/Sources/skit/README.md @@ -0,0 +1,73 @@ +# skit + +A CLI for SyntaxKit. Two verbs: + +``` +skit run Input.swift # render a SyntaxKit DSL file into Swift source +skit run Input.swift -o Out.swift +skit run InputDir/ -o OutDir/ # walk **/*.swift and mirror rendered output +skit parse < Input.swift # parse Swift source into a JSON syntax tree +``` + +`run` is the default subcommand, so `skit Input.swift` is shorthand for `skit run Input.swift`. + +## Quick start + +```bash +# Build a self-contained release bundle (binary + dylib + swiftmodules). +Scripts/build-skit.sh +# → .build/skit-release/{skit, lib/} + +cat > /tmp/Person.swift <<'SWIFT' +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +SWIFT + +.build/skit-release/skit /tmp/Person.swift +``` + +The bundle is portable: `cp -r .build/skit-release ~/anywhere/` and `~/anywhere/skit-release/skit ` works zero-config. + +## Input file shape + +`skit run` wraps each input in an implicit `Group { … }` builder. Top-level expressions become the builder's content; `import` declarations at the top are hoisted into the wrapper. + +```swift +// Models.swift +Struct("Person") { + Variable(.let, name: "name", type: "String") + Variable(.let, name: "age", type: "Int") +} +``` + +What *won't* work inside the input: top-level `let`/`var` outside the builder DSL, `@main`, `print` (the wrapper adds its own). Result-builder closure rules apply. + +## Cache + +One layer, keyed on content + toolchain + dylib stamp + `SKIT_*`/`SYNTAXKIT_*` env vars. Lives under `~/Library/Caches/com.brightdigit.SyntaxKit/outputs//output.swift` on macOS, `$XDG_CACHE_HOME/syntaxkit/outputs//output.swift` (or `~/.cache/syntaxkit/outputs//output.swift`) on Linux. On hit, `skit` skips the `swift` spawn for an input entirely. + +Output cache hit is roughly ~0.14s on macOS (no spawn at all); cold miss matches the warm `swift` script-mode baseline (~0.5s). Force a miss with `--no-cache`. + +## Flag reference (`skit run`) + +| Flag | Default | Meaning | +| ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-o, --output ` | stdout | Output file (single-file mode) or directory (folder mode). | +| `--lib ` | auto | Directory containing `libSyntaxKit.{dylib,so}` + module files. Search order when omitted: `$SKIT_LIB_DIR` → `/lib/` → `/../lib/skit/`. | +| `--no-cache` | (off) | Skip the output cache; always spawn `swift`. | +| `--timeout ` | `60` | Per-input timeout for the spawned `swift` (SIGTERM → 5s → SIGKILL). On expiry the file exits with code 124. Pass `0` to disable. | +| `--no-toolchain-check` | (off) | Skip the startup check that compares `lib/swift-version.txt` to `swift --version`. swiftmodules aren't reliably compatible across compiler versions; on mismatch skit refuses to spawn `swift` and points at the rebuild script. Auto-rebuild fallback tracked in [#157](https://github.com/brightdigit/SyntaxKit/issues/157). | + +## Platform notes + +- **macOS** — primary target. All build/release/test flows in `Scripts/`. +- **Linux** — verified on `swift:6.0-jammy/aarch64`. The Mach-O `install_name` step in `Scripts/build-skit.sh` is macOS-specific and skipped on Linux. +- **Windows** — not supported. + +Known Linux gotcha: `Foundation.Process.waitUntilExit()` hangs on already-exited children on `swift:6.0-jammy/aarch64`. `Runner.swift` works around it with `terminationHandler` + `DispatchSemaphore`. + +## Deeper dive + +- [`Docs/skit.md`](../../Docs/skit.md) — architecture, design decisions, trade-offs. diff --git a/Sources/skit/Render+Batch.swift b/Sources/skit/Render+Batch.swift new file mode 100644 index 00000000..4be2669a --- /dev/null +++ b/Sources/skit/Render+Batch.swift @@ -0,0 +1,154 @@ +// +// Render+Batch.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit + +extension Render { + /// Collects a directory's `.swift` inputs, reads them, renders them via the + /// filesystem-free `Runner.render(sources:)`, mirrors each rendered output + /// into `output`, prints per-input diagnostics (fenced when several files + /// emit them), prints a one-line summary, and maps any failures to + /// `ExitCode(1)` — Tuist-analog batch semantics. + internal static func renderBatch( + runner: Runner, + input: String, + output: String + ) async throws(RunCommandError) { + let inputURL = URL(fileURLWithPath: input).standardizedFileURL + let outputURL = URL(fileURLWithPath: output).standardizedFileURL + + let inputs: [URL] + do { + inputs = try FileManager.default.collectInputs(at: inputURL) + } catch { + throw RunCommandError(collectInputsError: error, input: input) + } + + if inputs.isEmpty { + FileHandle.standardError.write( + Data("\(Skit.Run.messagePrefix)no .swift inputs under \(input)\n".utf8) + ) + return + } + + // Read inputs into memory (read failures become per-file outcomes so a bad + // input doesn't abort the batch), render in-memory, then mirror each + // successful render into the output tree. + let (sources, readFailures) = loadSources(inputs) + let rendered = await runner.render(sources: sources) + let written = rendered.map { writeRendered($0, inputBase: inputURL, outputBase: outputURL) } + let outcomes = readFailures + written + + reportOutcomeDiagnostics(outcomes) + + FileHandle.standardError.write( + Data( + ("\(Skit.Run.messagePrefix)\(outcomes.count - outcomes.failureCount)" + + "/\(outcomes.count) succeeded\n").utf8 + ) + ) + + if outcomes.failureCount > 0 { + // Some inputs failed; if the toolchain couldn't be verified, hint once + // that a Swift-version mismatch may be behind the build errors above. + if let hint = RunCommandError.toolchainHint(runner.toolchainVerification) { + FileHandle.standardError.write(Data(hint.utf8)) + } + throw RunCommandError.failed + } + } + + /// Prints per-input diagnostics for a batch: each file's compiler stderr + /// (fenced with a `---- path ----` header), then for any non-`.renderFailed` + /// failure (whose stderr was just surfaced) a one-line `path: error`. Spawn/ + /// write failures carry their diagnostic in the error itself. + private static func reportOutcomeDiagnostics(_ outcomes: [FileOutcome]) { + for outcome in outcomes { + if !outcome.stderr.isEmpty { + FileHandle.standardError.write(Data("---- \(outcome.input.path) ----\n".utf8)) + FileHandle.standardError.write(Data(outcome.stderr.utf8)) + } + // .renderFailed already had its stderr surfaced above. Other failures + // (process spawn, write) carry the diagnostic in the error itself. + if let error = outcome.result { + if case .renderFailed = error { + continue + } + FileHandle.standardError.write(Data("\(outcome.input.path): \(error)\n".utf8)) + } + } + } + + /// Reads every input into a `RenderInput`. A read failure does not abort the + /// batch — it becomes a per-file `.unexpected` `FileOutcome`, returned + /// alongside the successfully-loaded sources. + private static func loadSources( + _ inputs: [URL] + ) -> (sources: [RenderInput], failures: [FileOutcome]) { + var sources: [RenderInput] = [] + var failures: [FileOutcome] = [] + for url in inputs { + do { + let source = try String(contentsOf: url, encoding: .utf8) + sources.append(RenderInput(url: url, source: source)) + } catch { + failures.append( + FileOutcome(input: url, stdout: Data(), stderr: "", result: .unexpected(error)) + ) + } + } + return (sources, failures) + } + + /// Mirrors a successful render's `stdout` to its destination under + /// `outputBase`, folding any write error into the returned outcome. Already- + /// failed outcomes pass through untouched. + private static func writeRendered( + _ outcome: FileOutcome, + inputBase: URL, + outputBase: URL + ) -> FileOutcome { + guard outcome.result == nil else { + return outcome + } + let destination = outcome.input.rerooted(from: inputBase, onto: outputBase) + do { + try FileManager.default.writeData(outcome.stdout, to: destination) + return outcome + } catch { + return FileOutcome( + input: outcome.input, + stdout: outcome.stdout, + stderr: outcome.stderr, + result: .unexpected(error) + ) + } + } +} diff --git a/Sources/skit/Render.swift b/Sources/skit/Render.swift new file mode 100644 index 00000000..f224ea9d --- /dev/null +++ b/Sources/skit/Render.swift @@ -0,0 +1,114 @@ +// +// Render.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit + +/// Render-side IO for the `skit run` subcommand: classifies the input, calls +/// the silent `Runner` SDK, and owns all stdout/stderr presentation and file +/// writes. Hosted in an `enum` namespace because none of these helpers use +/// instance state from `Skit.Run` — extracted from `Skit.Run` so the +/// subcommand struct stays focused on argument parsing. +internal enum Render { + /// Classifies `input` and dispatches to the matching `Runner` SDK method. + /// The SDK layer is silent — this function (and its helpers) own all + /// stdout/stderr presentation and file IO, and translate `RunError`/batch + /// failures into the typed `RunCommandError` that `Skit.Run.run()` maps to + /// a process exit. Success-path output is still written here. + internal static func render(using runner: Runner, input: String, output: String?) + async throws(RunCommandError) + { + let resolved: RunInput + do { + resolved = try RunInput.resolve(input: input, output: output) + } catch .invalidInput(let message) { + throw RunCommandError.usage(message) + } catch { + // RunInput.resolve only throws .invalidInput; defensive. + throw RunCommandError.failed + } + + switch resolved { + case .singleFile(let inputPath, let outputPath): + try await renderSingle(runner: runner, input: inputPath, output: outputPath) + case .directory(let inputDir, let outputDir): + try await renderBatch(runner: runner, input: inputDir, output: outputDir) + } + } + + /// Reads `input`, renders it via the filesystem-free `Runner.render(source:)`, + /// prints any compiler stderr, and writes the rendered Swift source either to + /// `output` or to stdout. The runner reads and writes nothing — that's the + /// CLI's job. + private static func renderSingle( + runner: Runner, + input: String, + output: String? + ) async throws(RunCommandError) { + let inputURL = URL(fileURLWithPath: input).standardizedFileURL + let source: String + do { + source = try String(contentsOf: inputURL, encoding: .utf8) + } catch { + throw RunCommandError.unexpected(error) + } + + let rendered: SingleFileRender + do { + rendered = try await runner.render(source: source, originalPath: inputURL.path) + } catch { + // `error` is typed `RunError` (render is `throws(RunError)`); the + // initializer's switch is exhaustive, so this single catch is total. + throw RunCommandError(renderFileError: error) + } + + try emit(rendered, to: output) + } + + /// Prints any diagnostics, then writes the rendered source to `output` (or + /// stdout when `output` is nil). A write failure has no diagnostic of its own, + /// so the underlying error is surfaced (ArgumentParser prints it, exit 1). + private static func emit( + _ rendered: SingleFileRender, + to output: String? + ) throws(RunCommandError) { + if !rendered.stderr.isEmpty { + FileHandle.standardError.write(Data(rendered.stderr.utf8)) + } + guard let output else { + FileHandle.standardOutput.write(rendered.stdout) + return + } + do { + try rendered.stdout.write(to: URL(fileURLWithPath: output)) + } catch { + throw RunCommandError.unexpected(error) + } + } +} diff --git a/Sources/skit/RunCommandError.swift b/Sources/skit/RunCommandError.swift new file mode 100644 index 00000000..55db1f92 --- /dev/null +++ b/Sources/skit/RunCommandError.swift @@ -0,0 +1,180 @@ +// +// RunCommandError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxKit + +/// Typed failures the `run` pipeline can produce. The render steps just +/// `throw` the case that describes *what* failed; `run()` catches these in +/// one place and converts each to its stderr diagnostic + process exit, so +/// the steps stay free of presentation/exit logic. +internal enum RunCommandError: Error { + /// A usage error (bad input path, or a directory given without `-o`). + /// Surfaced as an ArgumentParser `ValidationError` (exit 64), which prints + /// the message and the command's usage. + case usage(String) + /// The libSyntaxKit directory couldn't be resolved. Exit 2. + case libResolutionFailed(any Error) + /// The bundle's recorded `swift --version` differs from the local one. + /// Exit 2. + case toolchainMismatch(bundle: String, local: String) + /// The spawned `swift` exited non-zero (compile failure, `124` on timeout, + /// `128 + signal`). Carries that code + the toolchain stderr; the code is + /// passed through as the process exit. Also carries the session's + /// `toolchainVerification` so an unverified toolchain can be hinted as a + /// possible cause alongside the diagnostics. + case renderFailed(exitCode: Int32, stderr: String, toolchain: ToolchainVerification) + /// A directory batch couldn't be walked. Exit 1. + case directoryWalkFailed(input: String, underlying: any Error) + /// A render/batch failure whose diagnostics were already surfaced (per-input + /// output, a printed summary, or none). Exit 1 with nothing further to say. + case failed + /// A wrapped Foundation/Subprocess failure with no dedicated mapping; the + /// underlying error is rethrown so ArgumentParser prints it (exit 1). + case unexpected(any Error) + /// `run` was invoked on a platform without a Subprocess backend. Exit 1. + case unsupportedPlatform + + /// stderr text to emit before exiting, or nil. `.usage` is printed by + /// `ValidationError`; `.failed`/`.unexpected` print nothing here (their + /// diagnostics were already surfaced, or ArgumentParser prints them). + internal var diagnostic: String? { + switch self { + case .usage, .failed, .unexpected: + return nil + case .libResolutionFailed(let error): + return "\(error)\n" + case .toolchainMismatch(let bundle, let local): + return Self.toolchainMismatchMessage(bundle: bundle, local: local) + case .renderFailed(_, let stderr, let toolchain): + let parts = [stderr.isEmpty ? nil : stderr, Self.toolchainHint(toolchain)] + .compactMap { $0 } + return parts.isEmpty ? nil : parts.joined() + case .directoryWalkFailed(let input, let underlying): + return "\(Skit.Run.messagePrefix)failed to walk \(input): \(underlying)\n" + case .unsupportedPlatform: + return "\(Skit.Run.messagePrefix)run is not supported on this platform " + + "(no Subprocess backend).\n" + } + } + + /// The terminal error ArgumentParser acts on: a `ValidationError` (usage), + /// an `ExitCode`, or the wrapped underlying error. + internal var terminalError: any Error { + switch self { + case .usage(let message): + return ValidationError(message) + case .libResolutionFailed, .toolchainMismatch: + return ExitCode(2) + case .renderFailed(let exitCode, _, _): + return ExitCode(exitCode) + case .directoryWalkFailed, .failed, .unsupportedPlatform: + return ExitCode(1) + case .unexpected(let underlying): + return underlying + } + } + + /// Maps a SyntaxKit session-setup failure onto the CLI's exit policy. + internal init(_ error: RunnerSetupError) { + switch error { + case .libResolutionFailed(let underlying): + self = .libResolutionFailed(underlying) + case .toolchainMismatch(let bundle, let local): + self = .toolchainMismatch(bundle: bundle, local: local) + } + } + + /// Maps the `RunError` from `Runner.renderFile` onto the CLI's exit policy. + /// `renderFile`'s error is exhaustive over `RunError`, so every case has a + /// direct translation. + internal init(renderFileError error: RunError) { + switch error { + case .invalidInput(let message): + self = .usage(message) + case .renderFailed(let exitCode, let stderr, let toolchain): + self = .renderFailed(exitCode: exitCode, stderr: stderr, toolchain: toolchain) + case .unexpected(let underlying): + self = .unexpected(underlying) + } + } + + /// Maps the `CollectInputsError` from `FileManager.collectInputs` onto the + /// CLI's exit policy. Both cases are bulk directory-walk failures, carrying + /// `input` for the "failed to walk" framing the CLI prints. + internal init(collectInputsError error: CollectInputsError, input: String) { + switch error { + case .cliError(let underlying): + self = .directoryWalkFailed(input: input, underlying: underlying) + case .resourceValuesFailure(let underlying): + self = .directoryWalkFailed(input: input, underlying: underlying) + } + } + + /// A one-line note appended to a render failure when the bundle/local Swift + /// toolchain wasn't confirmed compatible — so a module-version error reads + /// as a possible toolchain mismatch rather than a mysterious build failure. + /// `nil` when the toolchain was verified (nothing to add). `internal` so the + /// directory-batch path can surface the same note once. + internal static func toolchainHint(_ verification: ToolchainVerification) -> String? { + switch verification { + case .verified: + return nil + case .notChecked: + return "\(Skit.Run.messagePrefix)note: the bundle/local Swift-toolchain check was skipped " + + "(--\(Skit.Run.noToolchainCheckFlagName)); if this is a module-version error, a " + + "toolchain mismatch may be the cause.\n" + case .unverified: + return + "\(Skit.Run.messagePrefix)note: couldn't verify the bundle's Swift toolchain against " + + "your local `swift` (no toolchain stamp, or version capture failed); if this is a " + + "module-version error, a toolchain mismatch may be the cause.\n" + } + } + + /// Human-readable error explaining why the bundle's recorded + /// `swift --version` differs from the local one, and how to recover. + private static func toolchainMismatchMessage(bundle: String, local: String) -> String { + """ + \(Skit.Run.messagePrefix)toolchain mismatch + bundle: \(bundle) + local: \(local) + The bundle's libSyntaxKit was built against a different `swift` than the + one on your PATH. Swift swiftmodules aren't reliably compatible across + versions, so spawning `swift` would fail with a cryptic module-version + diagnostic. + + Rebuild the bundle with: + \(Skit.Run.buildReleaseScriptPath) + Or pass --\(Skit.Run.noToolchainCheckFlagName) to try anyway. + + """ + } +} diff --git a/Sources/skit/Skit+Parse.swift b/Sources/skit/Skit+Parse.swift new file mode 100644 index 00000000..f54328b0 --- /dev/null +++ b/Sources/skit/Skit+Parse.swift @@ -0,0 +1,54 @@ +// +// Skit+Parse.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxParser + +extension Skit { + internal struct Parse: ParsableCommand { + /// The subcommand name as invoked on the command line. + internal static let commandName = "parse" + + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Parse Swift source on stdin into a JSON syntax tree on stdout." + ) + + internal func run() throws { + let code = + String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" + let treeNodes = SyntaxParser.parse(code: code) + let encoder = JSONEncoder() + let data = try encoder.encode(treeNodes) + let json = String(decoding: data, as: UTF8.self) + print(json) + } + } +} diff --git a/Sources/skit/Skit+Run.swift b/Sources/skit/Skit+Run.swift new file mode 100644 index 00000000..8613c2ff --- /dev/null +++ b/Sources/skit/Skit+Run.swift @@ -0,0 +1,191 @@ +// +// Skit+Run.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import ArgumentParser +import Foundation +import SyntaxKit + +extension Skit { + /// Render one or more SyntaxKit DSL files into Swift source. + /// + /// `Run` is the default subcommand. It accepts either a single `.swift` + /// file or a directory of `.swift` files; in directory mode the rendered + /// output is written into a mirrored tree under `-o`. The actual work is + /// delegated to a `Runner` value (`Runner.swift`). + internal struct Run: AsyncParsableCommand { + /// The subcommand name as invoked on the command line. + internal static let commandName = "run" + + /// User-facing option/flag names (the `--long` CLI surface). + internal static let outputOptionName = "output" + internal static let libOptionName = "lib" + internal static let noCacheFlagName = "no-cache" + internal static let clearCacheFlagName = "clear-cache" + internal static let timeoutOptionName = "timeout" + internal static let noToolchainCheckFlagName = "no-toolchain-check" + + /// Prefix for skit's own diagnostics written to stderr. + internal static let messagePrefix = "skit: " + + /// Argument passed to `swift` to capture the toolchain version banner. + internal static let versionFlag = "--version" + + /// Path to the script that rebuilds the self-contained release bundle. + /// Shown in error text as a repo-root-relative hint; it resolves only when + /// the user is in a SyntaxKit checkout (the script's intended use), not for + /// an installed binary run from an arbitrary CWD. + internal static let buildReleaseScriptPath = "Scripts/build-skit.sh" + + /// Largest timeout we can safely convert to nanoseconds without overflowing + /// the `UInt64` multiplication in `Task.timeout` (`UInt64(seconds) * 1e9`). + internal static let maxTimeoutSeconds = Int(UInt64.max / 1_000_000_000) + + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Render SyntaxKit DSL input(s) into Swift source." + ) + + @Argument(help: "Path to a .swift input file or a directory of inputs.") + internal var input: String + + @Option( + name: [.short, .customLong(Run.outputOptionName)], + help: "Output file (single-file mode) or directory (folder mode)." + ) + internal var output: String? + + @Option( + name: .customLong(Run.libOptionName), + help: "Directory containing libSyntaxKit.dylib + module files." + ) + internal var libPath: String? + + @Flag( + name: .customLong(Run.noCacheFlagName), + help: "Skip the rendered-output cache (always run swift)." + ) + internal var noCache: Bool = false + + @Flag( + name: .customLong(Run.clearCacheFlagName), + help: "Purge every rendered-output cache entry before rendering." + ) + internal var clearCache: Bool = false + + @Option( + name: .customLong(Run.timeoutOptionName), + help: "Per-input timeout for the spawned `swift` in seconds (0 disables)." + ) + internal var timeoutSeconds: Int = 60 + + @Flag( + name: .customLong(Run.noToolchainCheckFlagName), + help: "Skip the bundle/local Swift-toolchain comparison." + ) + internal var noToolchainCheck: Bool = false + + internal func validate() throws { + guard timeoutSeconds >= 0 else { + throw ValidationError( + "--\(Self.timeoutOptionName) expects a non-negative integer (seconds), " + + "got: \(timeoutSeconds)" + ) + } + // Upper bound: the value is later multiplied by 1_000_000_000 into a + // `UInt64` of nanoseconds, which overflows silently past this point. + guard timeoutSeconds <= Self.maxTimeoutSeconds else { + throw ValidationError( + "--\(Self.timeoutOptionName) is too large (max \(Self.maxTimeoutSeconds) seconds), " + + "got: \(timeoutSeconds)" + ) + } + } + + internal func execute() async throws(RunCommandError) { + // The spawn backend is nil only where there's no Subprocess backend + // (Windows, embedded) — there `run` can't spawn `swift`/`swiftc`. + guard let backend = Self.swiftBackend else { + throw RunCommandError.unsupportedPlatform + } + + // Capture the two backend-dependent inputs to the otherwise + // platform-agnostic session setup: the local `swift --version` (feeds + // the toolchain check + the cache key, spawned exactly once per run) + // and the SKIT_LIB_DIR override. + let swiftVersion = await backend.captureSwiftVersion() + let envLibPath = ProcessInfo.processInfo.skitLibPath + + // Purge the rendered-output cache up front when asked. The cache root is + // derived from the environment alone (not `swiftVersion`), so a throwaway + // instance targets the same directory the Runner's cache will use. A + // clear failure is non-fatal: the worst case is a stale entry survives + // and the next run re-uses it. + if clearCache { + try? OutputCache(swiftVersion: swiftVersion).clear() + } + + // Resolve lib dir → toolchain-gate → cache → assemble Runner. That + // orchestration lives in SyntaxKit (the `Runner` session initializer); + // skit injects the captured `swiftVersion` and the backend's spawn + // method, and maps the typed setup failure onto the CLI's exit policy. + let runner: Runner + do { + runner = try Runner( + libCandidates: [self.libPath, envLibPath], + swiftVersion: swiftVersion, + enforceToolchainCheck: !noToolchainCheck, + useCache: !noCache, + timeoutSeconds: timeoutSeconds + ) { try await backend.runSwift(for: $0) } + } catch { + throw RunCommandError(error) + } + + // Hand the input off to the runner: `Render` owns presentation and the + // single-file/directory dispatch, translating render failures into + // `RunCommandError` for the outer catch to map. + try await Render.render(using: runner, input: input, output: output) + } + + /// Renders the input(s), with a single seam between the pipeline and the + /// process: every step `throw`s a typed `RunCommandError`, and the outer catch + /// maps that to its stderr diagnostic + terminal `ExitCode`/`ValidationError`. + /// Any non-`RunCommandError` propagates to ArgumentParser unchanged. + internal func run() async throws { + do { + try await self.execute() + } catch { + if let diagnostic = error.diagnostic { + FileHandle.standardError.write(Data(diagnostic.utf8)) + } + throw error.terminalError + } + } + } +} diff --git a/Sources/skit/Skit.swift b/Sources/skit/Skit.swift index 1066b5e2..d500b9de 100644 --- a/Sources/skit/Skit.swift +++ b/Sources/skit/Skit.swift @@ -27,24 +27,31 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import SyntaxParser +import ArgumentParser +/// The `skit` CLI entry point. +/// +/// `Skit` itself is just an ArgumentParser shell that wires up two subcommands: +/// `Run` (the default, rendering SyntaxKit DSL into Swift source) and `Parse` +/// (the inverse, reading Swift source on stdin and emitting JSON). Their bodies +/// live in `Skit+Run.swift` and `Skit+Parse.swift` respectively. @main -internal enum Skit { - internal static func main() throws { - // Read Swift code from stdin - let code = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" +internal struct Skit: AsyncParsableCommand { + /// The top-level command name as invoked on the command line. + internal static let commandName = "skit" - // Parse the code using SyntaxKit - let treeNodes = SyntaxParser.parse(code: code) + /// Name of the `swift` executable resolved on `PATH`. Shared by the + /// toolchain-version capture and the Subprocess `swift` configuration. + internal static let swiftExecutableName = "swift" - // Convert to JSON for output - let encoder = JSONEncoder() - let data = try encoder.encode(treeNodes) - let json = String(decoding: data, as: UTF8.self) + /// Name of the `swiftc` compiler resolved on `PATH`. Used to compile a + /// wrapped DSL program into a temporary executable before running it. + internal static let swiftcExecutableName = "swiftc" - // Output the JSON - print(json) - } + internal static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "Render SyntaxKit DSL into Swift source, or parse Swift into JSON.", + subcommands: [Run.self, Parse.self], + defaultSubcommand: Run.self + ) } diff --git a/Sources/skit/Subprocess.Configuration+Swift.swift b/Sources/skit/Subprocess.Configuration+Swift.swift new file mode 100644 index 00000000..403b0dfb --- /dev/null +++ b/Sources/skit/Subprocess.Configuration+Swift.swift @@ -0,0 +1,148 @@ +// +// Subprocess.Configuration+Swift.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Foundation + import Subprocess + import SyntaxKit + + // System (FilePath) ships in the Apple SDK on Darwin; non-Apple platforms + // need swift-system's `SystemPackage` product to get the same `FilePath`. + #if canImport(System) + import System + #else + import SystemPackage + #endif + + extension Subprocess.Configuration { + /// Bounded output capacity for the spawned process (16 MiB stdout / 1 MiB + /// stderr). Output above this is exotic; we'd surface a clear SubprocessError + /// rather than silently truncate. + private static let stdoutLimitBytes = 16 * 1_024 * 1_024 + private static let stderrLimitBytes = 1 * 1_024 * 1_024 + + /// Subdirectory of the lib dir holding the SwiftSyntax CShims headers. + private static let cShimsIncludeSuffix = "_SwiftSyntaxCShims-include" + + /// Filename prefix for the per-render temporary executable. + private static let renderExecutablePrefix = "skit-render-" + + /// swiftc flags used to build the compile invocation. + private static let flagSuppressWarnings = "-suppress-warnings" + private static let flagInclude = "-I" + private static let flagLibrarySearchPath = "-L" + private static let flagLinkSyntaxKit = "-lSyntaxKit" + private static let flagPassToClang = "-Xcc" + private static let flagPassToLinker = "-Xlinker" + private static let flagRPath = "-rpath" + private static let flagOutput = "-o" + + /// A configuration that compiles `wrappedPath` into an executable at + /// `outputPath`, linked against `libSyntaxKit` in `libPath`. + /// + /// Builds the full flag set: link against libSyntaxKit, include the CShims + /// headers, and bake an rpath so the produced binary loads the dylib at + /// runtime. `swiftc` is used rather than the `swift` interpreter because the + /// interpreter's JIT does not load the dynamic libSyntaxKit's symbols. The + /// executable is resolved by name on `PATH`. + internal static func compileSwift( + libPath: String, + wrappedPath: String, + outputPath: String + ) -> Self { + let cShimsInclude = "\(libPath)/\(cShimsIncludeSuffix)" + let arguments: [String] = [ + flagSuppressWarnings, + flagInclude, libPath, + flagLibrarySearchPath, libPath, + flagLinkSyntaxKit, + flagPassToClang, flagInclude, flagPassToClang, cShimsInclude, + flagPassToLinker, flagRPath, flagPassToLinker, libPath, + flagOutput, outputPath, + wrappedPath, + ] + return Self(executable: .name(Skit.swiftcExecutableName), arguments: Arguments(arguments)) + } + + /// Renders the `invocation` by compiling the wrapped DSL program with + /// `swiftc` and then running the produced executable, whose stdout is the + /// generated Swift source. This is the Subprocess backend skit hands to + /// `Runner` as its `run` closure — the one seam between the (platform- + /// agnostic) engine in SyntaxKit and the Subprocess implementation. + /// + /// A compile failure short-circuits and is reported with the compiler's + /// exit code + stderr (the render layer maps any non-zero exit to a render + /// failure). A successful compile is followed by running the binary and + /// reporting its result. A completed spawn always reports `.completed`; the + /// timeout race that can produce `.timedOut` lives in `Runner`. + internal static func runSwift( + for invocation: SyntaxKit.SwiftInvocation + ) async throws -> SwiftRunOutcome { + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(renderExecutablePrefix)\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: outputURL) } + + // 1. Compile the wrapped program to a temporary executable. + let compile = try await Subprocess.run( + .compileSwift( + libPath: invocation.libPath, + wrappedPath: invocation.wrappedPath, + outputPath: outputURL.path + ), + output: .discarded, + error: .string(limit: stderrLimitBytes) + ) + guard compile.terminationStatus.exitCode == 0 else { + return .completed( + ProcessResult( + exitCode: compile.terminationStatus.exitCode, + stdout: Data(), + stderr: compile.standardError ?? "" + ) + ) + } + + // 2. Run the compiled program; its stdout is the rendered Swift source. + let render = try await Subprocess.run( + .path(FilePath(outputURL.path)), + output: .string(limit: stdoutLimitBytes), + error: .string(limit: stderrLimitBytes) + ) + return .completed( + ProcessResult( + exitCode: render.terminationStatus.exitCode, + stdout: Data((render.standardOutput ?? "").utf8), + stderr: render.standardError ?? "" + ) + ) + } + } + +#endif diff --git a/Sources/skit/SubprocessBackend.swift b/Sources/skit/SubprocessBackend.swift new file mode 100644 index 00000000..6ad70930 --- /dev/null +++ b/Sources/skit/SubprocessBackend.swift @@ -0,0 +1,71 @@ +// +// SubprocessBackend.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import SyntaxKit + +#if canImport(Subprocess) + + import Subprocess + + /// Subprocess-backed `SwiftBackend`: the real `swift`-spawning implementation + /// skit hands to the render session. The one seam between the platform- + /// agnostic engine in SyntaxKit and the Subprocess implementation. + internal struct SubprocessBackend: SwiftBackend { + /// Verbatim `swift --version` output, or nil on spawn failure. Capped at 4 KiB. + internal func captureSwiftVersion() async -> String? { + let result = try? await Subprocess.run( + .name(Skit.swiftExecutableName), + arguments: [Skit.Run.versionFlag], + output: .string(limit: 4_096), + error: .discarded + ) + return result?.standardOutput + } + + internal func runSwift( + for invocation: SwiftInvocation + ) async throws -> SwiftRunOutcome { + try await Subprocess.Configuration.runSwift(for: invocation) + } + } + + extension Skit.Run { + /// The active spawn backend, or nil when this platform has no Subprocess + /// backend (Windows, embedded) and `run` therefore can't spawn `swift`. + internal static let swiftBackend: (any SwiftBackend)? = SubprocessBackend() + } + +#else + + extension Skit.Run { + /// No Subprocess backend on this platform — `run` reports `unsupportedPlatform`. + internal static let swiftBackend: (any SwiftBackend)? = nil + } + +#endif diff --git a/Sources/skit/TerminationStatus+ExitCode.swift b/Sources/skit/TerminationStatus+ExitCode.swift new file mode 100644 index 00000000..aa0900e8 --- /dev/null +++ b/Sources/skit/TerminationStatus+ExitCode.swift @@ -0,0 +1,49 @@ +// +// TerminationStatus+ExitCode.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Subprocess + + extension TerminationStatus { + /// Collapses the termination status into a single exit code, using the + /// shell convention (128 + signal number) for signalled deaths. + internal var exitCode: Int32 { + switch self { + case .exited(let code): + return Int32(truncatingIfNeeded: code) + #if !os(Windows) + case .signaled(let signal): + return 128 + Int32(truncatingIfNeeded: signal) + #endif + } + } + } + +#endif diff --git a/Tests/SyntaxDocTests/DocumentationExampleTests.swift b/Tests/SyntaxDocTests/DocumentationExampleTests.swift index d2222f75..c9c4f709 100644 --- a/Tests/SyntaxDocTests/DocumentationExampleTests.swift +++ b/Tests/SyntaxDocTests/DocumentationExampleTests.swift @@ -1,3 +1,32 @@ +// +// DocumentationExampleTests.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 DocumentationHarness import Foundation import Testing diff --git a/Tests/SyntaxDocTests/DocumentationTestError.swift b/Tests/SyntaxDocTests/DocumentationTestError.swift index 56dda8f0..59aecda6 100644 --- a/Tests/SyntaxDocTests/DocumentationTestError.swift +++ b/Tests/SyntaxDocTests/DocumentationTestError.swift @@ -1,3 +1,32 @@ +// +// DocumentationTestError.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation internal enum DocumentationTestError: Error, CustomStringConvertible { diff --git a/Tests/SyntaxDocTests/Settings.swift b/Tests/SyntaxDocTests/Settings.swift index 92161966..1dba7b28 100644 --- a/Tests/SyntaxDocTests/Settings.swift +++ b/Tests/SyntaxDocTests/Settings.swift @@ -2,7 +2,29 @@ // Settings.swift // SyntaxKit // -// Created by Leo Dion on 9/5/25. +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. // import Foundation diff --git a/Tests/SyntaxKitTests/Integration/BlackjackCardTests.swift b/Tests/SyntaxKitTests/Integration/BlackjackCardTests.swift index 7cf55ec5..08dcae06 100644 --- a/Tests/SyntaxKitTests/Integration/BlackjackCardTests.swift +++ b/Tests/SyntaxKitTests/Integration/BlackjackCardTests.swift @@ -1,3 +1,32 @@ +// +// BlackjackCardTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import SyntaxKit import Testing diff --git a/Tests/SyntaxKitTests/Integration/BlackjackTests.swift b/Tests/SyntaxKitTests/Integration/BlackjackTests.swift index 46ca6f73..8b4be73a 100644 --- a/Tests/SyntaxKitTests/Integration/BlackjackTests.swift +++ b/Tests/SyntaxKitTests/Integration/BlackjackTests.swift @@ -1,3 +1,32 @@ +// +// BlackjackTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import SyntaxKit import Testing @@ -131,11 +160,15 @@ internal struct BlackjackTests { Variable(.let, name: "suit", type: "Suit") ComputedProperty("description", type: "String") { Variable(.var, name: "output", equals: Literal.string("suit is \\(suit.rawValue),")) - PlusAssign("output", " value is \\(rank.values.first)") + Infix( + "+=", + lhs: VariableExp("output"), + rhs: Literal.string(" value is \\(rank.values.first)") + ) If( Let("second", "rank.values.second"), then: { - PlusAssign("output", " or \\(second)") + Infix("+=", lhs: VariableExp("output"), rhs: Literal.string(" or \\(second)")) } ) Return { diff --git a/Tests/SyntaxKitTests/Integration/CommentTests.swift b/Tests/SyntaxKitTests/Integration/CommentTests.swift index 92a270de..bd90a473 100644 --- a/Tests/SyntaxKitTests/Integration/CommentTests.swift +++ b/Tests/SyntaxKitTests/Integration/CommentTests.swift @@ -1,3 +1,32 @@ +// +// CommentTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import SyntaxKit import Testing diff --git a/Tests/SyntaxKitTests/Integration/CompleteProtocolsExampleTests.swift b/Tests/SyntaxKitTests/Integration/CompleteProtocolsExampleTests.swift index 986e834b..4884d274 100644 --- a/Tests/SyntaxKitTests/Integration/CompleteProtocolsExampleTests.swift +++ b/Tests/SyntaxKitTests/Integration/CompleteProtocolsExampleTests.swift @@ -1,13 +1,13 @@ // // CompleteProtocolsExampleTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -51,13 +51,13 @@ import Testing Extension("Vehicle") { Function("start") { Call("print") { - ParameterExp(name: "", value: "\"Starting \\(brand) vehicle...\"") + ParameterExp(name: "", value: VariableExp("\"Starting \\(brand) vehicle...\"")) } } Function("stop") { Call("print") { - ParameterExp(name: "", value: "\"Stopping \\(brand) vehicle...\"") + ParameterExp(name: "", value: VariableExp("\"Stopping \\(brand) vehicle...\"")) } } } @@ -81,7 +81,7 @@ import Testing Function("start") { Call("print") { - ParameterExp(name: "", value: "\"Starting \\(brand) car engine...\"") + ParameterExp(name: "", value: VariableExp("\"Starting \\(brand) car engine...\"")) } } } @@ -97,7 +97,7 @@ import Testing Function("charge") { Call("print") { - ParameterExp(name: "", value: "\"Charging \\(brand) electric car...\"") + ParameterExp(name: "", value: VariableExp("\"Charging \\(brand) electric car...\"")) } Assignment("batteryLevel", Literal.float(100.0)) } @@ -126,13 +126,16 @@ import Testing // Demonstrate protocol usage Function("demonstrateVehicle") { - Parameter(name: "vehicle", type: "Vehicle", isUnnamed: true) + Parameter(unlabeled: "vehicle", type: "Vehicle") } _: { Call("print") { - ParameterExp(name: "", value: "\"Vehicle brand: \\(vehicle.brand)\"") + ParameterExp(name: "", value: VariableExp("\"Vehicle brand: \\(vehicle.brand)\"")) } Call("print") { - ParameterExp(name: "", value: "\"Number of wheels: \\(vehicle.numberOfWheels)\"") + ParameterExp( + name: "", + value: VariableExp("\"Number of wheels: \\(vehicle.numberOfWheels)\"") + ) } VariableExp("vehicle").call("start") VariableExp("vehicle").call("stop") @@ -143,13 +146,13 @@ import Testing // Demonstrate protocol composition Function("demonstrateElectricVehicle") { - Parameter(name: "vehicle", type: "Vehicle & Electric", isUnnamed: true) + Parameter(unlabeled: "vehicle", type: "Vehicle & Electric") } _: { Call("demonstrateVehicle") { - ParameterExp(name: "", value: "vehicle") + ParameterExp(name: "", value: VariableExp("vehicle")) } Call("print") { - ParameterExp(name: "", value: "\"Battery level: \\(vehicle.batteryLevel)%\"") + ParameterExp(name: "", value: VariableExp("\"Battery level: \\(vehicle.batteryLevel)%\"")) } VariableExp("vehicle").call("charge") } @@ -159,20 +162,20 @@ import Testing // Test the implementations Call("print") { - ParameterExp(name: "", value: "\"Testing regular car:\"") + ParameterExp(name: "", value: VariableExp("\"Testing regular car:\"")) } .comment { Line("Test the implementations") } Call("demonstrateVehicle") { - ParameterExp(name: "", value: "toyota") + ParameterExp(name: "", value: VariableExp("toyota")) } Call("print") { - ParameterExp(name: "", value: "\"Testing electric car:\"") + ParameterExp(name: "", value: VariableExp("\"Testing electric car:\"")) } Call("demonstrateElectricVehicle") { - ParameterExp(name: "", value: "tesla") + ParameterExp(name: "", value: VariableExp("tesla")) } } } diff --git a/Tests/SyntaxKitTests/Integration/ConcurrencyExampleTests.swift b/Tests/SyntaxKitTests/Integration/ConcurrencyExampleTests.swift index b9c83e86..c815dc2c 100644 --- a/Tests/SyntaxKitTests/Integration/ConcurrencyExampleTests.swift +++ b/Tests/SyntaxKitTests/Integration/ConcurrencyExampleTests.swift @@ -1,9 +1,39 @@ +// +// ConcurrencyExampleTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import SyntaxKit import Testing @Suite internal struct ConcurrencyExampleTests { @Test("Concurrency vending machine DSL generates expected Swift code") + @available(*, deprecated, message: "Exercises deprecated Infix builder closure init") internal func testConcurrencyVendingMachineExample() throws { // Build DSL equivalent of Examples/Remaining/concurrency/dsl.swift // Note: This test includes the Item struct that's referenced but not defined in the original DSL diff --git a/Tests/SyntaxKitTests/Integration/ConditionalsExampleTests.swift b/Tests/SyntaxKitTests/Integration/ConditionalsExampleTests.swift index c418e699..290f4c00 100644 --- a/Tests/SyntaxKitTests/Integration/ConditionalsExampleTests.swift +++ b/Tests/SyntaxKitTests/Integration/ConditionalsExampleTests.swift @@ -1,3 +1,32 @@ +// +// ConditionalsExampleTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import SyntaxKit import Testing @@ -7,21 +36,18 @@ import Testing internal func testCompletedConditionalsExample() throws { // Build DSL equivalent of Examples/Completed/conditionals/dsl.swift - let program = try Group { + let program = Group { // MARK: Basic If Statements Variable(.let, name: "temperature", equals: 25) .comment { Line("Simple if statement") } - try If { - try Infix(">") { - VariableExp("temperature") - Literal.integer(30) - } + If { + Infix(">", lhs: VariableExp("temperature"), rhs: Literal.integer(30)) } then: { Call("print") { - ParameterExp(unlabeled: "\"It's hot outside!\"") + ParameterExp(unlabeled: VariableExp("\"It's hot outside!\"")) } } @@ -31,38 +57,29 @@ import Testing Line("If-else statement") } - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(90) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { Call("print") { - ParameterExp(unlabeled: "\"Excellent!\"") + ParameterExp(unlabeled: VariableExp("\"Excellent!\"")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(80) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { Call("print") { - ParameterExp(unlabeled: "\"Good job!\"") + ParameterExp(unlabeled: VariableExp("\"Good job!\"")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(70) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(70)) } then: { Call("print") { - ParameterExp(unlabeled: "\"Passing\"") + ParameterExp(unlabeled: VariableExp("\"Passing\"")) } } else: { Call("print") { - ParameterExp(unlabeled: "\"Needs improvement\"") + ParameterExp(unlabeled: VariableExp("\"Needs improvement\"")) } } } @@ -81,8 +98,9 @@ import Testing Call("print") { ParameterExp( name: "", - value: + value: VariableExp( "\"The string \"\\(possibleNumber)\" has an integer value of \\(actualNumber)\"" + ) ) } }, @@ -90,7 +108,9 @@ import Testing Call("print") { ParameterExp( name: "", - value: "\"The string \"\\(possibleNumber)\" could not be converted to an integer\"" + value: VariableExp( + "\"The string \"\\(possibleNumber)\" could not be converted to an integer\"" + ) ) } } @@ -110,12 +130,12 @@ import Testing Let("age", "possibleAge") } then: { Call("print") { - ParameterExp(name: "", value: "\"\\(name) is \\(age) years old\"") + ParameterExp(name: "", value: VariableExp("\"\\(name) is \\(age) years old\"")) } } // MARK: - Guard Statements - try Function( + Function( "greet", { Parameter(name: "person", type: "[String: String]") @@ -125,7 +145,7 @@ import Testing Let("name", "person[\"name\"]") } else: { Call("print") { - ParameterExp(name: "", value: "\"No name provided\"") + ParameterExp(name: "", value: VariableExp("\"No name provided\"")) } } @@ -134,12 +154,15 @@ import Testing Let("ageInt", "Int(age)") } else: { Call("print") { - ParameterExp(name: "", value: "\"Invalid age provided\"") + ParameterExp(name: "", value: VariableExp("\"Invalid age provided\"")) } } Call("print") { - ParameterExp(name: "", value: "\"Hello \\(name), you are \\(ageInt) years old\"") + ParameterExp( + name: "", + value: VariableExp("\"Hello \\(name), you are \\(ageInt) years old\"") + ) } } ) @@ -176,7 +199,10 @@ import Testing } } Call("print") { - ParameterExp(name: "", value: "\"There are \\(naturalCount) \\(countedThings).\"") + ParameterExp( + name: "", + value: VariableExp("\"There are \\(naturalCount) \\(countedThings).\"") + ) } // MARK: - Tuple literal and tuple pattern switch @@ -187,30 +213,32 @@ import Testing Switch("somePoint") { SwitchCase(Tuple.pattern([0, 0])) { Call("print") { - ParameterExp(name: "", value: "\"(0, 0) is at the origin\"") + ParameterExp(name: "", value: VariableExp("\"(0, 0) is at the origin\"")) } } SwitchCase(Tuple.pattern([nil, 0])) { Call("print") { - ParameterExp(name: "", value: "\"(\\(somePoint.0), 0) is on the x-axis\"") + ParameterExp(name: "", value: VariableExp("\"(\\(somePoint.0), 0) is on the x-axis\"")) } } SwitchCase(Tuple.pattern([0, nil])) { Call("print") { - ParameterExp(name: "", value: "\"(0, \\(somePoint.1)) is on the y-axis\"") + ParameterExp(name: "", value: VariableExp("\"(0, \\(somePoint.1)) is on the y-axis\"")) } } SwitchCase(Tuple.pattern([(-2...2), (-2...2)])) { Call("print") { ParameterExp( - name: "", value: "\"(\\(somePoint.0), \\(somePoint.1)) is inside the box\"" + name: "", + value: VariableExp("\"(\\(somePoint.0), \\(somePoint.1)) is inside the box\"") ) } } Default { Call("print") { ParameterExp( - name: "", value: "\"(\\(somePoint.0), \\(somePoint.1)) is outside of the box\"" + name: "", + value: VariableExp("\"(\\(somePoint.0), \\(somePoint.1)) is outside of the box\"") ) } } @@ -225,23 +253,17 @@ import Testing Switch("anotherPoint") { SwitchCase(Tuple.pattern([Pattern.let("x"), 0])) { Call("print") { - ParameterExp( - name: "", value: "\"on the x-axis with an x value of \\(x)\"" - ) + ParameterExp(name: "", value: VariableExp("\"on the x-axis with an x value of \\(x)\"")) } } SwitchCase(Tuple.pattern([0, Pattern.let("y")])) { Call("print") { - ParameterExp( - name: "", value: "\"on the y-axis with a y value of \\(y)\"" - ) + ParameterExp(name: "", value: VariableExp("\"on the y-axis with a y value of \\(y)\"")) } } SwitchCase(Tuple.pattern([Pattern.let("x"), Pattern.let("y")])) { Call("print") { - ParameterExp( - name: "", value: "\"somewhere else at (\\(x), \\(y))\"" - ) + ParameterExp(name: "", value: VariableExp("\"somewhere else at (\\(x), \\(y))\"")) } } } @@ -255,15 +277,19 @@ import Testing 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") + Infix( + "+=", + lhs: VariableExp("description"), + rhs: Literal.string(" a prime number, and also") + ) Fallthrough() } Default { - PlusAssign("description", " an integer.") + Infix("+=", lhs: VariableExp("description"), rhs: Literal.string(" an integer.")) } } Call("print") { - ParameterExp(name: "", value: "description") + ParameterExp(name: "", value: VariableExp("description")) } // MARK: - Labeled Statements @@ -272,15 +298,12 @@ import Testing Line("MARK: - Labeled Statements") Line("Using labeled statements with break") } - try Variable(.var, name: "board") { - try Init("[Int]") { + Variable(.var, name: "board") { + Init("[Int]") { ParameterExp(name: "repeating", value: Literal.integer(0)) ParameterExp( name: "count", - value: try Infix("+") { - VariableExp("finalSquare") - Literal.integer(1) - } + value: Infix("+", lhs: VariableExp("finalSquare"), rhs: Literal.integer(1)) ) } } @@ -297,48 +320,30 @@ import Testing Variable(.var, name: "square", equals: Literal.integer(0)) Variable(.var, name: "diceRoll", equals: Literal.integer(0)) - try While( - try Infix("!=") { - VariableExp("square") - VariableExp("finalSquare") - } + While( + Infix("!=", lhs: VariableExp("square"), rhs: VariableExp("finalSquare")) ) { - PlusAssign("diceRoll", 1) - try If { - try Infix("==") { - VariableExp("diceRoll") - Literal.integer(7) - } + Infix("+=", lhs: VariableExp("diceRoll"), rhs: Literal.integer(1)) + If { + Infix("==", lhs: VariableExp("diceRoll"), rhs: Literal.integer(7)) } then: { Assignment("diceRoll", 1) } - try Switch( - try Infix("+") { - VariableExp("square") - VariableExp("diceRoll") - } + Switch( + Infix("+", lhs: VariableExp("square"), rhs: VariableExp("diceRoll")) ) { SwitchCase("finalSquare") { Break() } - try SwitchCase { + SwitchCase { SwitchLet("newSquare") - try Infix(">") { - VariableExp("newSquare") - VariableExp("finalSquare") - } + Infix(">", lhs: VariableExp("newSquare"), rhs: VariableExp("finalSquare")) } content: { Continue() } - try Default { - try Infix("+=") { - VariableExp("square") - VariableExp("diceRoll") - } - try Infix("+=") { - VariableExp("square") - VariableExp("board[square]") - } + Default { + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("diceRoll")) + Infix("+=", lhs: VariableExp("square"), rhs: VariableExp("board[square]")) } } } @@ -498,48 +503,36 @@ import Testing @Test("Conditionals example generates correct syntax") internal func testConditionalsExample() throws { - _ = try If { - try Infix(">") { - VariableExp("temperature") - Literal.integer(30) - } + _ = If { + Infix(">", lhs: VariableExp("temperature"), rhs: Literal.integer(30)) } then: { Call("print") { - ParameterExp(unlabeled: "It's hot!") + ParameterExp(unlabeled: VariableExp("It's hot!")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(90) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { Call("print") { - ParameterExp(unlabeled: "Excellent!") + ParameterExp(unlabeled: VariableExp("Excellent!")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(80) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { Call("print") { - ParameterExp(unlabeled: "Good!") + ParameterExp(unlabeled: VariableExp("Good!")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(70) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(70)) } then: { Call("print") { - ParameterExp(unlabeled: "Pass") + ParameterExp(unlabeled: VariableExp("Pass")) } } else: { Call("print") { - ParameterExp(unlabeled: "Fail") + ParameterExp(unlabeled: VariableExp("Fail")) } } } diff --git a/Tests/SyntaxKitTests/Integration/ForLoopsExampleTests.swift b/Tests/SyntaxKitTests/Integration/ForLoopsExampleTests.swift index 740f0134..10aa0dd0 100644 --- a/Tests/SyntaxKitTests/Integration/ForLoopsExampleTests.swift +++ b/Tests/SyntaxKitTests/Integration/ForLoopsExampleTests.swift @@ -1,3 +1,32 @@ +// +// ForLoopsExampleTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import SyntaxKit import Testing @@ -7,7 +36,7 @@ import Testing internal func testCompletedForLoopsExample() throws { // Build DSL equivalent of Examples/Completed/for_loops/dsl.swift - let program = try Group { + let program = Group { // MARK: - Basic For-in Loop Variable( .let, @@ -28,14 +57,14 @@ import Testing in: VariableExp("names"), then: { Call("print") { - ParameterExp(unlabeled: "\"Hello, \\(name)!\"") + ParameterExp(unlabeled: VariableExp("\"Hello, \\(name)!\"")) } } ) // MARK: - For-in with Enumerated Call("print") { - ParameterExp(unlabeled: "\"\\n=== For-in with Enumerated ===\"") + ParameterExp(unlabeled: VariableExp("\"\\n=== For-in with Enumerated ===\"")) } .comment { Line("MARK: - For-in with Enumerated") @@ -49,14 +78,14 @@ import Testing in: VariableExp("names").call("enumerated"), then: { Call("print") { - ParameterExp(unlabeled: "\"Index: \\(index), Name: \\(name)\"") + ParameterExp(unlabeled: VariableExp("\"Index: \\(index), Name: \\(name)\"")) } } ) // MARK: - For-in with Where Clause Call("print") { - ParameterExp(unlabeled: "\"\\n=== For-in with Where Clause ===\"") + ParameterExp(unlabeled: VariableExp("\"\\n=== For-in with Where Clause ===\"")) } .comment { Line("MARK: - For-in with Where Clause") @@ -79,28 +108,26 @@ import Testing ]) ) - try For( + 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)\"") + ParameterExp(unlabeled: VariableExp("\"Even number: \\(number)\"")) } } ) // MARK: - For-in with Dictionary Call("print") { - ParameterExp(unlabeled: "\"\\n=== For-in with Dictionary ===\"") + ParameterExp(unlabeled: VariableExp("\"\\n=== For-in with Dictionary ===\"")) } .comment { Line("MARK: - For-in with Dictionary") @@ -124,7 +151,7 @@ import Testing in: VariableExp("scores"), then: { Call("print") { - ParameterExp(unlabeled: "\"\\(name): \\(score)\"") + ParameterExp(unlabeled: VariableExp("\"\\(name): \\(score)\"")) } } ) diff --git a/Tests/SyntaxKitTests/Integration/SimpleDocTests.swift b/Tests/SyntaxKitTests/Integration/SimpleDocTests.swift index af89e6e5..215a1323 100644 --- a/Tests/SyntaxKitTests/Integration/SimpleDocTests.swift +++ b/Tests/SyntaxKitTests/Integration/SimpleDocTests.swift @@ -1,3 +1,32 @@ +// +// SimpleDocTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift b/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift new file mode 100644 index 00000000..0542f925 --- /dev/null +++ b/Tests/SyntaxKitTests/Integration/SkitSubprocessTimeoutTests.swift @@ -0,0 +1,101 @@ +// +// SkitSubprocessTimeoutTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Subprocess) + + import Foundation + import Subprocess + import Testing + + // Regression test for swift-subprocess #256: + // + // + // The skit timeout watchdog (Sources/skit/Runner.swift) races a Subprocess + // run() call against `Task.sleep(timeout)`. On timeout it calls + // `group.cancelAll()`, which Subprocess turns into a teardown sequence on + // the spawned `swift`. The reported bug: when the spawned child has a + // grandchild that inherited the pipe FDs, the parent's stream read can hang + // waiting for an EOF that won't arrive until the grandchild exits. + // + // skit's real-world trigger is `swift Input.swift`, which fork-exec's the + // Swift frontend + linker as grandchildren. We approximate that here with a + // shell pipeline that forks a background `sleep` holding stderr open. + @Suite("Subprocess timeout-cancel") + internal struct SkitSubprocessTimeoutTests { + private enum Outcome: Equatable, Sendable { + case completed(TerminationStatus) + case timedOut + } + + @Test( + "cancel-on-timeout completes within a bounded wall-time when grandchildren hold pipe fds" + ) + internal func cancelWithBackgroundedGrandchild() async throws { + let start = ContinuousClock.now + + let outcome: Outcome = try await withThrowingTaskGroup(of: Outcome.self) { group in + group.addTask { + // Outer shell spawns a backgrounded grandchild that holds stderr + // open for 30s, then itself sleeps 30s. Both must be forcibly + // killed by the teardown to free our stream reads. + let record = try await run( + .name("sh"), + arguments: ["-c", "(sleep 30 >/dev/null 2>&1 &) ; sleep 30"], + output: .discarded, + error: .discarded + ) + return .completed(record.terminationStatus) + } + group.addTask { + try await Task.sleep(for: .seconds(1)) + return .timedOut + } + let first = try #require(await group.next()) + group.cancelAll() + return first + } + + let elapsed = ContinuousClock.now - start + + #expect( + outcome == .timedOut, + "timeout task should win the race against a 30s sleep" + ) + // Generous bound: cancellation + Subprocess teardown should complete + // well under 15s. If swift-subprocess #256 triggers, this test fails + // (the run task hangs ≥30s waiting for the grandchild to release the + // stderr pipe). + #expect( + elapsed < .seconds(15), + "timeout-cancel took \(elapsed); possible swift-subprocess #256 regression" + ) + } + } + +#endif diff --git a/Tests/SyntaxKitTests/Integration/SwiftUIExampleTests.swift b/Tests/SyntaxKitTests/Integration/SwiftUIExampleTests.swift index d0529eff..d1843aa0 100644 --- a/Tests/SyntaxKitTests/Integration/SwiftUIExampleTests.swift +++ b/Tests/SyntaxKitTests/Integration/SwiftUIExampleTests.swift @@ -1,13 +1,13 @@ // // SwiftUIExampleTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Platform.swift b/Tests/SyntaxKitTests/Platform.swift new file mode 100644 index 00000000..022a8cf1 --- /dev/null +++ b/Tests/SyntaxKitTests/Platform.swift @@ -0,0 +1,43 @@ +// +// Platform.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Compile-time platform facts surfaced as runtime booleans for use with +/// Swift Testing's `.enabled(if:)` / `.disabled(if:)` traits. +internal enum Platform { + /// True on WebAssembly/WASI. The render pipeline relies on host filesystem + /// behavior (atomic temp writes) and ultimately spawning `swift`, neither of + /// which is available there, so render tests are disabled on this platform. + internal static let isWASI: Bool = { + #if os(WASI) + return true + #else + return false + #endif + }() +} diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift new file mode 100644 index 00000000..d85c700e --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Arguments.swift @@ -0,0 +1,137 @@ +// +// AttributeTests+Arguments.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit +import Testing + +extension AttributeTests { + @Suite("Arguments") internal struct Arguments { + @Test("Attribute with arguments generates correct syntax") + internal func testAttributeWithArguments() throws { + let attribute = Attribute("available", arguments: ["iOS", "17.0", "*"]) + + let generated = attribute.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("*")) + } + + @Test("Attribute with single argument generates correct syntax") + internal func testAttributeWithSingleArgument() throws { + let attribute = Attribute("available", argument: "iOS 17.0") + + let generated = attribute.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS 17.0")) + } + + @Test("Function with attribute arguments generates correct syntax") + internal func testFunctionWithAttributeArguments() throws { + let function = Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + }.attribute("available", arguments: ["iOS", "17.0", "*"]) + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("*")) + #expect(generated.contains("func bar")) + } + + @Test("Class with attribute arguments generates correct syntax") + internal func testClassWithAttributeArguments() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + }.attribute("available", arguments: ["iOS", "17.0"]) + + let generated = classDecl.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("class Foo")) + } + + @Test("Variable with attribute arguments generates correct syntax") + internal func testVariableWithAttributeArguments() throws { + let variable = Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("available", arguments: ["iOS", "17.0"]) + + let generated = variable.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("var bar")) + } + + @Test("Struct with quoted string attribute argument generates string literal, not identifier") + internal func testStructWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: "\"App Model\"" must produce @Suite("App Model"), not @Suite(App Model) + let structDecl = Struct("AppModelTests") {} + .attribute("Suite", arguments: ["\"App Model\""]) + + let generated = structDecl.syntax.description + #expect( + generated.contains("@Suite(\"App Model\")") || generated.contains("@Suite( \"App Model\")")) + #expect(!generated.contains("@Suite(App Model)")) + } + + @Test("Function with quoted string attribute argument generates string literal") + internal func testFunctionWithStringLiteralAttributeArgument() throws { + // Fix 3 regression: quoted argument must produce string literal token + let function = Function("initialCount") {} + .attribute("Test", arguments: ["\"Initial count is zero\""]) + + let generated = function.syntax.description + // The argument should be a string literal: @Test("Initial count is zero") + #expect( + generated.contains("@Test(\"Initial count is zero\")") + || generated.contains("@Test( \"Initial count is zero\")")) + } + + @Test("Parameter with attribute arguments generates correct syntax") + internal func testParameterWithAttributeArguments() throws { + let function = Function("validate") { + Parameter(name: "input", type: "String") + .attribute("available", arguments: ["iOS", "17.0"]) + } _: { + Variable(.let, name: "result", type: "Bool", equals: "true") + } + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("iOS")) + #expect(generated.contains("17.0")) + #expect(generated.contains("input : String")) + #expect(generated.contains("func validate")) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift new file mode 100644 index 00000000..59236b7a --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests+Targets.swift @@ -0,0 +1,137 @@ +// +// AttributeTests+Targets.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import SyntaxKit +import Testing + +extension AttributeTests { + @Suite("Targets") internal struct Targets { + @Test("Class with attribute generates correct syntax") + internal func testClassWithAttribute() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + }.attribute("objc") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("class Foo")) + } + + @Test("Function with attribute generates correct syntax") + internal func testFunctionWithAttribute() throws { + let function = Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + }.attribute("available") + + let generated = function.syntax.description + #expect(generated.contains("@available")) + #expect(generated.contains("func bar")) + } + + @Test("Variable with attribute generates correct syntax") + internal func testVariableWithAttribute() throws { + let variable = Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("Published") + + let generated = variable.syntax.description + #expect(generated.contains("@Published")) + #expect(generated.contains("var bar")) + } + + @Test("Multiple attributes on class generates correct syntax") + internal func testMultipleAttributesOnClass() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + } + .attribute("objc") + .attribute("MainActor") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("@MainActor")) + #expect(generated.contains("class Foo")) + } + + @Test("Comprehensive attribute example generates correct syntax") + internal func testComprehensiveAttributeExample() throws { + let classDecl = Class("Foo") { + Variable(.var, name: "bar", type: "String", equals: "bar") + .attribute("Published") + + Function("bar") { + Variable(.let, name: "message", type: "String", equals: "bar") + } + .attribute("available") + .attribute("MainActor") + + Function("baz") { + Variable(.let, name: "message", type: "String", equals: "baz") + } + .attribute("MainActor") + }.attribute("objc") + + let generated = classDecl.syntax.description + #expect(generated.contains("@objc")) + #expect(generated.contains("@Published")) + #expect(generated.contains("@available")) + #expect(generated.contains("@MainActor")) + #expect(generated.contains("class Foo")) + #expect(generated.contains("var bar")) + #expect(generated.contains("func bar")) + #expect(generated.contains("func baz")) + } + + @Test("Parameter with attribute generates correct syntax") + internal func testParameterWithAttribute() throws { + let function = Function("process") { + Parameter(name: "data", type: "Data") + .attribute("escaping") + } _: { + Variable(.let, name: "result", type: "String", equals: "processed") + } + + let generated = function.syntax.description + #expect(generated.contains("@escaping")) + #expect(generated.contains("data : Data")) + #expect(generated.contains("func process")) + } + + @Test("Struct with unquoted attribute argument generates identifier, not string literal") + internal func testStructWithIdentifierAttributeArgument() throws { + // Unquoted args should remain as identifier references + let structDecl = Struct("Serve") {} + .attribute("main") + + let generated = structDecl.syntax.description + #expect(generated.contains("@main")) + #expect(generated.contains("struct Serve")) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift index 432fdbd6..88d33b3f 100644 --- a/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift +++ b/Tests/SyntaxKitTests/Unit/Attributes/AttributeTests.swift @@ -1,13 +1,13 @@ // // AttributeTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -27,174 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import SyntaxKit import Testing -@Suite internal struct AttributeTests { - @Test("Class with attribute generates correct syntax") - internal func testClassWithAttribute() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - }.attribute("objc") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("class Foo")) - } - - @Test("Function with attribute generates correct syntax") - internal func testFunctionWithAttribute() throws { - let function = Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - }.attribute("available") - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("func bar")) - } - - @Test("Variable with attribute generates correct syntax") - internal func testVariableWithAttribute() throws { - let variable = Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("Published") - - let generated = variable.syntax.description - #expect(generated.contains("@Published")) - #expect(generated.contains("var bar")) - } - - @Test("Multiple attributes on class generates correct syntax") - internal func testMultipleAttributesOnClass() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - } - .attribute("objc") - .attribute("MainActor") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("@MainActor")) - #expect(generated.contains("class Foo")) - } - - @Test("Attribute with arguments generates correct syntax") - internal func testAttributeWithArguments() throws { - let attribute = Attribute("available", arguments: ["iOS", "17.0", "*"]) - - let generated = attribute.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("*")) - } - - @Test("Attribute with single argument generates correct syntax") - internal func testAttributeWithSingleArgument() throws { - let attribute = Attribute("available", argument: "iOS 17.0") - - let generated = attribute.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS 17.0")) - } - - @Test("Comprehensive attribute example generates correct syntax") - internal func testComprehensiveAttributeExample() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("Published") - - Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - } - .attribute("available") - .attribute("MainActor") - - Function("baz") { - Variable(.let, name: "message", type: "String", equals: "baz") - } - .attribute("MainActor") - }.attribute("objc") - - let generated = classDecl.syntax.description - #expect(generated.contains("@objc")) - #expect(generated.contains("@Published")) - #expect(generated.contains("@available")) - #expect(generated.contains("@MainActor")) - #expect(generated.contains("class Foo")) - #expect(generated.contains("var bar")) - #expect(generated.contains("func bar")) - #expect(generated.contains("func baz")) - } - - @Test("Function with attribute arguments generates correct syntax") - internal func testFunctionWithAttributeArguments() throws { - let function = Function("bar") { - Variable(.let, name: "message", type: "String", equals: "bar") - }.attribute("available", arguments: ["iOS", "17.0", "*"]) - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("*")) - #expect(generated.contains("func bar")) - } - - @Test("Class with attribute arguments generates correct syntax") - internal func testClassWithAttributeArguments() throws { - let classDecl = Class("Foo") { - Variable(.var, name: "bar", type: "String", equals: "bar") - }.attribute("available", arguments: ["iOS", "17.0"]) - - let generated = classDecl.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("class Foo")) - } - - @Test("Variable with attribute arguments generates correct syntax") - internal func testVariableWithAttributeArguments() throws { - let variable = Variable(.var, name: "bar", type: "String", equals: "bar") - .attribute("available", arguments: ["iOS", "17.0"]) - - let generated = variable.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("var bar")) - } - - @Test("Parameter with attribute generates correct syntax") - internal func testParameterWithAttribute() throws { - let function = Function("process") { - Parameter(name: "data", type: "Data") - .attribute("escaping") - } _: { - Variable(.let, name: "result", type: "String", equals: "processed") - } - - let generated = function.syntax.description - #expect(generated.contains("@escaping")) - #expect(generated.contains("data : Data")) - #expect(generated.contains("func process")) - } - - @Test("Parameter with attribute arguments generates correct syntax") - internal func testParameterWithAttributeArguments() throws { - let function = Function("validate") { - Parameter(name: "input", type: "String") - .attribute("available", arguments: ["iOS", "17.0"]) - } _: { - Variable(.let, name: "result", type: "Bool", equals: "true") - } - - let generated = function.syntax.description - #expect(generated.contains("@available")) - #expect(generated.contains("iOS")) - #expect(generated.contains("17.0")) - #expect(generated.contains("input : String")) - #expect(generated.contains("func validate")) - } -} +/// Namespace for the attribute generation test suites. +@Suite("Attributes") internal enum AttributeTests {} diff --git a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentAsyncTests.swift b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentAsyncTests.swift index f92d1bbb..bb1506d7 100644 --- a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentAsyncTests.swift +++ b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentAsyncTests.swift @@ -1,3 +1,32 @@ +// +// TupleAssignmentAsyncTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentBasicTests.swift b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentBasicTests.swift index ba739056..18bfd557 100644 --- a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentBasicTests.swift @@ -1,3 +1,32 @@ +// +// TupleAssignmentBasicTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentEdgeCaseTests.swift b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentEdgeCaseTests.swift index cbb5b2c1..5b9fd529 100644 --- a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentEdgeCaseTests.swift +++ b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentEdgeCaseTests.swift @@ -1,3 +1,32 @@ +// +// TupleAssignmentEdgeCaseTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentIntegrationTests.swift b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentIntegrationTests.swift index 87f2046c..2405e0a0 100644 --- a/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentIntegrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/Collections/TupleAssignmentIntegrationTests.swift @@ -1,3 +1,32 @@ +// +// TupleAssignmentIntegrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ControlFlow/ConditionalsTests.swift b/Tests/SyntaxKitTests/Unit/ControlFlow/ConditionalsTests.swift index b0d23783..e8f3a95b 100644 --- a/Tests/SyntaxKitTests/Unit/ControlFlow/ConditionalsTests.swift +++ b/Tests/SyntaxKitTests/Unit/ControlFlow/ConditionalsTests.swift @@ -1,3 +1,32 @@ +// +// ConditionalsTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -7,33 +36,27 @@ import Testing @Test("If / else-if / else chain generates correct syntax") internal func testIfElseChain() throws { // Arrange: build the DSL example using the updated APIs - let conditional = try Group { + let conditional = Group { Variable(.let, name: "score", type: "Int", equals: "85") - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(90) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { Call("print") { - ParameterExp(name: "", value: "\"Excellent!\"") + ParameterExp(name: "", value: VariableExp("\"Excellent!\"")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(80) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { Call("print") { - ParameterExp(name: "", value: "\"Good job!\"") + ParameterExp(name: "", value: VariableExp("\"Good job!\"")) } } Then { Call("print") { - ParameterExp(name: "", value: "\"Needs improvement\"") + ParameterExp(name: "", value: VariableExp("\"Needs improvement\"")) } } } @@ -51,24 +74,18 @@ import Testing @Test("If with multiple conditions generates correct syntax") internal func testIfWithMultipleConditions() throws { - let ifStatement = try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(90) - } + let ifStatement = If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(90)) } then: { Call("print") { - ParameterExp(unlabeled: "Excellent!") + ParameterExp(unlabeled: VariableExp("Excellent!")) } } else: { - try If { - try Infix(">=") { - VariableExp("score") - Literal.integer(80) - } + If { + Infix(">=", lhs: VariableExp("score"), rhs: Literal.integer(80)) } then: { Call("print") { - ParameterExp(unlabeled: "Good!") + ParameterExp(unlabeled: VariableExp("Good!")) } } } diff --git a/Tests/SyntaxKitTests/Unit/ControlFlow/ForLoopTests.swift b/Tests/SyntaxKitTests/Unit/ControlFlow/ForLoopTests.swift index 8d0b3039..a7783265 100644 --- a/Tests/SyntaxKitTests/Unit/ControlFlow/ForLoopTests.swift +++ b/Tests/SyntaxKitTests/Unit/ControlFlow/ForLoopTests.swift @@ -1,3 +1,32 @@ +// +// ForLoopTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -12,30 +41,26 @@ internal final class ForLoopTests { in: VariableExp("items"), then: { Call("print") { - ParameterExp(name: "", value: "item") + ParameterExp(name: "", value: VariableExp("item")) } } ) let generated = forLoop.syntax.description - let expected = "for item in items {\n print(item)\n}" #expect(generated.contains("for item in items")) #expect(generated.contains("print(item)")) } @Test internal func testForInWithWhereClause() throws { - let forLoop = try For( + let forLoop = For( VariableExp("number"), in: VariableExp("numbers"), where: { - try Infix("%") { - VariableExp("number") - Literal.integer(2) - } + Infix("%", lhs: VariableExp("number"), rhs: Literal.integer(2)) }, then: { Call("print") { - ParameterExp(name: "", value: "number") + ParameterExp(name: "", value: VariableExp("number")) } } ) diff --git a/Tests/SyntaxKitTests/Unit/Core/PatternConvertibleTests.swift b/Tests/SyntaxKitTests/Unit/Core/PatternConvertibleTests.swift index ca914cf3..1d698db0 100644 --- a/Tests/SyntaxKitTests/Unit/Core/PatternConvertibleTests.swift +++ b/Tests/SyntaxKitTests/Unit/Core/PatternConvertibleTests.swift @@ -1,13 +1,13 @@ // // PatternConvertibleTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -55,7 +55,7 @@ internal struct PatternConvertibleTests { @Test internal func testLetBindingPatternInSwitchCase() { let switchCase = SwitchCase(Tuple.pattern([Pattern.let("x"), Pattern.let("y")])) { Call("print") { - ParameterExp(name: "", value: "\"somewhere else at (\\(x), \\(y))\"") + ParameterExp(name: "", value: VariableExp("\"somewhere else at (\\(x), \\(y))\"")) } } diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift new file mode 100644 index 00000000..00e1a96b --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Declarations.swift @@ -0,0 +1,159 @@ +// +// ClassTests+Declarations.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +extension ClassTests { + @Suite("Declarations") internal struct Declarations { + @Test internal func testClassWithInheritance() { + let carClass = Class("Car") { + Variable(.var, name: "brand", type: "String").withExplicitType() + Variable(.var, name: "numberOfWheels", type: "Int").withExplicitType() + }.inherits("Vehicle") + + let expected = """ + class Car: Vehicle { + var brand: String + var numberOfWheels: Int + } + """ + + let normalizedGenerated = carClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testEmptyClass() { + let emptyClass = Class("EmptyClass") {} + + let expected = """ + class EmptyClass { + } + """ + + let normalizedGenerated = emptyClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithGenerics() { + let genericClass = Class("Container") { + Variable(.var, name: "value", type: "T").withExplicitType() + }.generic("T") + + let expected = """ + class Container { + var value: T + } + """ + + let normalizedGenerated = genericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithMultipleGenerics() { + let multiGenericClass = Class("Pair") { + Variable(.var, name: "first", type: "T").withExplicitType() + Variable(.var, name: "second", type: "U").withExplicitType() + }.generic("T", "U") + + let expected = """ + class Pair { + var first: T + var second: U + } + """ + + let normalizedGenerated = multiGenericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithMultipleInheritance() { + let classWithMultipleInheritance = Class("AdvancedVehicle") { + Variable(.var, name: "speed", type: "Int").withExplicitType() + }.inherits("Vehicle") + + let expected = """ + class AdvancedVehicle: Vehicle { + var speed: Int + } + """ + + let normalizedGenerated = classWithMultipleInheritance.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithGenericsAndInheritance() { + let genericClassWithInheritance = Class("GenericContainer") { + Variable(.var, name: "items", type: "[T]").withExplicitType() + }.generic("T").inherits("Collection") + + let expected = """ + class GenericContainer: Collection { + var items: [T] + } + """ + + let normalizedGenerated = genericClassWithInheritance.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testClassWithFunctions() { + let classWithFunctions = Class("Calculator") { + Function("add", returns: "Int") { + Parameter(name: "a", type: "Int") + Parameter(name: "b", type: "Int") + } _: { + Return { + VariableExp("a + b") + } + } + } + + let expected = """ + class Calculator { + func add(a: Int, b: Int) -> Int { + return a + b + } + } + """ + + let normalizedGenerated = classWithFunctions.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift new file mode 100644 index 00000000..2996797f --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests+Modifiers.swift @@ -0,0 +1,114 @@ +// +// ClassTests+Modifiers.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +extension ClassTests { + @Suite("Modifiers") internal struct Modifiers { + @Test internal func testFinalClass() { + let finalClass = Class("FinalClass") { + Variable(.var, name: "value", type: "String").withExplicitType() + }.final() + + let expected = """ + final class FinalClass { + var value: String + } + """ + + let normalizedGenerated = finalClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testFinalClassWithInheritanceAndGenerics() { + let finalGenericClass = Class("FinalGenericClass") { + Variable(.var, name: "value", type: "T").withExplicitType() + }.generic("T").inherits("BaseClass").final() + + let expected = """ + final class FinalGenericClass: BaseClass { + var value: T + } + """ + + let normalizedGenerated = finalGenericClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicClass() { + let publicClass = Class("AppModel") {}.access(.public) + + let expected = """ + public class AppModel { + } + """ + + let normalizedGenerated = publicClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicFinalClass() throws { + let publicFinalClass = Class("AppModel") {} + .attribute("Observable") + .access(.public) + .final() + .inherits("Sendable") + + let generated = publicFinalClass.generateCode() + // Fix 2 regression: Class must support .access() + #expect(generated.contains("public")) + #expect(generated.contains("final")) + #expect(generated.contains("class AppModel")) + #expect(generated.contains("Sendable")) + // Access modifier must precede final + let publicRange = try #require(generated.range(of: "public")) + let finalRange = try #require(generated.range(of: "final")) + #expect(publicRange.lowerBound < finalRange.lowerBound) + } + + @Test internal func testInternalClass() { + let internalClass = Class("MyClass") {}.access(.internal) + + let expected = """ + internal class MyClass { + } + """ + + let normalizedGenerated = internalClass.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift index 1cc8ff8d..995afe5f 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ClassTests.swift @@ -1,160 +1,33 @@ -import Foundation -import Testing - -@testable import SyntaxKit - -internal struct ClassTests { - @Test internal func testClassWithInheritance() { - let carClass = Class("Car") { - Variable(.var, name: "brand", type: "String").withExplicitType() - Variable(.var, name: "numberOfWheels", type: "Int").withExplicitType() - }.inherits("Vehicle") - - let expected = """ - class Car: Vehicle { - var brand: String - var numberOfWheels: Int - } - """ - - let normalizedGenerated = carClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testEmptyClass() { - let emptyClass = Class("EmptyClass") {} - - let expected = """ - class EmptyClass { - } - """ - - let normalizedGenerated = emptyClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithGenerics() { - let genericClass = Class("Container") { - Variable(.var, name: "value", type: "T").withExplicitType() - }.generic("T") - - let expected = """ - class Container { - var value: T - } - """ - - let normalizedGenerated = genericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithMultipleGenerics() { - let multiGenericClass = Class("Pair") { - Variable(.var, name: "first", type: "T").withExplicitType() - Variable(.var, name: "second", type: "U").withExplicitType() - }.generic("T", "U") - - let expected = """ - class Pair { - var first: T - var second: U - } - """ - - let normalizedGenerated = multiGenericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } +// +// ClassTests.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. +// - @Test internal func testFinalClass() { - let finalClass = Class("FinalClass") { - Variable(.var, name: "value", type: "String").withExplicitType() - }.final() - - let expected = """ - final class FinalClass { - var value: String - } - """ - - let normalizedGenerated = finalClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithMultipleInheritance() { - let classWithMultipleInheritance = Class("AdvancedVehicle") { - Variable(.var, name: "speed", type: "Int").withExplicitType() - }.inherits("Vehicle") - - let expected = """ - class AdvancedVehicle: Vehicle { - var speed: Int - } - """ - - let normalizedGenerated = classWithMultipleInheritance.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithGenericsAndInheritance() { - let genericClassWithInheritance = Class("GenericContainer") { - Variable(.var, name: "items", type: "[T]").withExplicitType() - }.generic("T").inherits("Collection") - - let expected = """ - class GenericContainer: Collection { - var items: [T] - } - """ - - let normalizedGenerated = genericClassWithInheritance.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testFinalClassWithInheritanceAndGenerics() { - let finalGenericClass = Class("FinalGenericClass") { - Variable(.var, name: "value", type: "T").withExplicitType() - }.generic("T").inherits("BaseClass").final() - - let expected = """ - final class FinalGenericClass: BaseClass { - var value: T - } - """ - - let normalizedGenerated = finalGenericClass.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } - - @Test internal func testClassWithFunctions() { - let classWithFunctions = Class("Calculator") { - Function("add", returns: "Int") { - Parameter(name: "a", type: "Int") - Parameter(name: "b", type: "Int") - } _: { - Return { - VariableExp("a + b") - } - } - } - - let expected = """ - class Calculator { - func add(a: Int, b: Int) -> Int { - return a + b - } - } - """ +import Testing - let normalizedGenerated = classWithFunctions.generateCode().normalize() - let normalizedExpected = expected.normalize() - #expect(normalizedGenerated == normalizedExpected) - } -} +/// Namespace for the `Class` declaration test suites. +@Suite("Class") internal enum ClassTests {} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ExtensionTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ExtensionTests.swift index be84e01f..091c6cdb 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ExtensionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ExtensionTests.swift @@ -1,13 +1,13 @@ // // ExtensionTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift new file mode 100644 index 00000000..93fc42c2 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/ImportTests.swift @@ -0,0 +1,60 @@ +// +// ImportTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct ImportTests { + @Test internal func testBasicImport() { + let importDecl = Import("Foundation") + + let generated = importDecl.generateCode() + #expect(generated.normalize() == "import Foundation") + } + + @Test internal func testImportWithTestableAttribute() { + let importDecl = Import("XCTest").attribute("testable") + + let generated = importDecl.generateCode() + // Fix 1 regression: must have a space between @testable and import + #expect(generated.contains("@testable import")) + #expect(!generated.contains("@testableimport")) + #expect(generated.contains("XCTest")) + } + + @Test internal func testImportWithGenericAttribute() { + let importDecl = Import("Foundation").attribute("_implementationOnly") + + let generated = importDecl.generateCode() + #expect(generated.contains("@_implementationOnly import")) + #expect(generated.contains("Foundation")) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift new file mode 100644 index 00000000..0c5e4888 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/InitializerDeclTests.swift @@ -0,0 +1,166 @@ +// +// InitializerDeclTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct InitializerDeclTests { + @Test internal func testEmptyInit() { + let initDecl = InitializerDecl {} + + let expected = """ + init() { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicInit() { + let initDecl = InitializerDecl {}.access(.public) + + let expected = """ + public init() { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testThrowingInit() { + let initDecl = InitializerDecl {}.throwing() + + let expected = """ + init() throws { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testAsyncInit() { + let initDecl = InitializerDecl {}.async() + + let expected = """ + init() async { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testPublicInitWithBody() { + let initDecl = InitializerDecl { + Call("setup") + }.access(.internal) + + let expected = """ + internal init() { + setup() + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testAsyncThrowingInit() { + let initDecl = InitializerDecl {}.async().throwing() + + // Fix 2 regression: async and throws must be single-spaced, not "async throws". + let generated = initDecl.syntax.description + #expect(generated.contains("async throws")) + #expect(!generated.contains("async throws")) + + let expected = """ + init() async throws { + } + """ + #expect(initDecl.generateCode().normalize() == expected.normalize()) + } + + @Test internal func testInitWithParameters() { + let initDecl = InitializerDecl { + Parameter(name: "name", type: "String") + Parameter(name: "age", type: "Int") + } _: { + Call("print") { + ParameterExp(unlabeled: Literal.string("hi")) + } + } + + let expected = """ + init(name: String, age: Int) { + print("hi") + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testInitWithParameterDefault() { + let initDecl = InitializerDecl { + Parameter(name: "count", type: "Int", defaultValue: "0") + } _: { + } + + let generated = initDecl.generateCode().normalize() + #expect(generated.contains("count: Int = 0")) + } + + @Test internal func testPublicInitWithParameters() { + let initDecl = InitializerDecl { + Parameter(name: "value", type: "String") + } _: { + } + .access(.public) + + let expected = """ + public init(value: String) { + } + """ + + let normalizedGenerated = initDecl.generateCode().normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift new file mode 100644 index 00000000..9f68e7c4 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Declarations/PoundIfTests.swift @@ -0,0 +1,202 @@ +// +// PoundIfTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +internal struct PoundIfTests { + @Test internal func testCanImport() { + let block = PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("import SwiftUI")) + #expect(generated.contains("#endif")) + } + + @Test internal func testFlag() { + let block = PoundIf(.flag("DEBUG")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if DEBUG")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testOS() { + let block = PoundIf(.os(.iOS)) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if os(iOS)")) + #expect(generated.contains("import UIKit")) + } + + @Test internal func testArch() { + let block = PoundIf(.arch(.arm64)) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if arch(arm64)")) + } + + @Test internal func testTargetEnvironment() { + let block = PoundIf(.targetEnvironment(.simulator)) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if targetEnvironment(simulator)")) + } + + @Test internal func testSwiftVersion() { + let block = PoundIf(.swift(.atLeast(5, 9))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if swift(>=5.9)")) + } + + @Test internal func testCompilerVersion() { + let block = PoundIf(.compiler(.atLeast(5, 9))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if compiler(>=5.9)")) + } + + @Test internal func testHasFeature() { + let block = PoundIf(.hasFeature("StrictConcurrency")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if hasFeature(StrictConcurrency)")) + } + + @Test internal func testHasAttribute() { + let block = PoundIf(.hasAttribute("retroactive")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if hasAttribute(retroactive)")) + } + + @Test internal func testAnd() { + let block = PoundIf(.and(.os(.iOS), .arch(.arm64))) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("os(iOS) && arch(arm64)")) + } + + @Test internal func testOrNot() { + let block = PoundIf(.or(.canImport("UIKit"), .not(.os(.macOS)))) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("canImport(UIKit) || !os(macOS)")) + } + + @Test internal func testRawStringCondition() { + let block = PoundIf("CUSTOM_FLAG") { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if CUSTOM_FLAG")) + } + + @Test internal func testCodeBlockCondition() { + let block = PoundIf(VariableExp("MY_FLAG")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if MY_FLAG")) + } + + @Test internal func testElseif() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .elseif(.canImport("UIKit")) { + Import("UIKit") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#elseif canImport(UIKit)")) + #expect(generated.contains("import UIKit")) + } + + @Test internal func testElse() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .else { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#else")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testElseifElse() { + let block = + PoundIf(.canImport("SwiftUI")) { + Import("SwiftUI") + } + .elseif(.canImport("UIKit")) { + Import("UIKit") + } + .else { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(SwiftUI)")) + #expect(generated.contains("#elseif canImport(UIKit)")) + #expect(generated.contains("#else")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } + + @Test internal func testFormerIfCanImportShape() { + let block = PoundIf(.canImport("Foundation")) { + Import("Foundation") + } + let generated = block.generateCode().normalize() + #expect(generated.contains("#if canImport(Foundation)")) + #expect(generated.contains("import Foundation")) + #expect(generated.contains("#endif")) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Declarations/ProtocolTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/ProtocolTests.swift index 5e847ac1..7fdcc7a0 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/ProtocolTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/ProtocolTests.swift @@ -1,3 +1,32 @@ +// +// ProtocolTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Declarations/StructTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/StructTests.swift index b46a2829..51a0cb19 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/StructTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/StructTests.swift @@ -1,3 +1,32 @@ +// +// StructTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -9,10 +38,10 @@ internal struct StructTests { Variable(.var, name: "items", type: "[Element]", equals: Literal.array([])).withExplicitType() Function("push") { - Parameter(name: "item", type: "Element", isUnnamed: true) + Parameter(unlabeled: "item", type: "Element") } _: { VariableExp("items").call("append") { - ParameterExp(name: "", value: "item") + ParameterExp(name: "", value: VariableExp("item")) } }.mutating() diff --git a/Tests/SyntaxKitTests/Unit/Declarations/TypeAliasTests.swift b/Tests/SyntaxKitTests/Unit/Declarations/TypeAliasTests.swift index ce05d1c3..8ee9c10e 100644 --- a/Tests/SyntaxKitTests/Unit/Declarations/TypeAliasTests.swift +++ b/Tests/SyntaxKitTests/Unit/Declarations/TypeAliasTests.swift @@ -1,13 +1,13 @@ // // TypeAliasTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTests.swift b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTests.swift index 17855a6c..0a5f1c7d 100644 --- a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTests.swift +++ b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTests.swift @@ -1,3 +1,32 @@ +// +// EdgeCaseTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -7,6 +36,7 @@ internal struct EdgeCaseTests { // MARK: - Error Handling Tests @Test("Infix with wrong number of operands throws error") + @available(*, deprecated, message: "Exercises the deprecated Infix(_:_:) builder init") internal func testInfixWrongOperandCount() throws { // Test that Infix throws an error when given wrong number of operands do { @@ -15,18 +45,18 @@ internal struct EdgeCaseTests { VariableExp("x") } // If we reach here, no error was thrown, which is unexpected - #expect(false, "Expected error to be thrown for wrong operand count") + Issue.record("Expected error to be thrown for wrong operand count") } catch let error as Infix.InfixError { // Verify it's the correct error type switch error { - case let .wrongOperandCount(expected, got): + case .wrongOperandCount(let expected, let got): #expect(expected == 2) #expect(got == 1) case .nonExprCodeBlockOperand: - #expect(false, "Expected wrongOperandCount error, got nonExprCodeBlockOperand") + Issue.record("Expected wrongOperandCount error, got nonExprCodeBlockOperand") } } catch { - #expect(false, "Expected InfixError, got \(type(of: error))") + Issue.record("Expected InfixError, got \(type(of: error))") } } diff --git a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsExpressions.swift b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsExpressions.swift index bdd74438..8df03f90 100644 --- a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsExpressions.swift +++ b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsExpressions.swift @@ -1,3 +1,32 @@ +// +// EdgeCaseTestsExpressions.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import SwiftSyntax import Testing @@ -38,20 +67,15 @@ internal struct EdgeCaseTestsExpressions { @Test("Infix with complex expressions generates correct syntax") internal func testInfixWithComplexExpressions() throws { - let infix = try Infix("*") { - try Parenthesized { - try Infix("+") { - VariableExp("a") - VariableExp("b") - } - } - try Parenthesized { - try Infix("-") { - VariableExp("c") - VariableExp("d") - } + let infix = Infix( + "*", + lhs: Parenthesized { + Infix("+", lhs: VariableExp("a"), rhs: VariableExp("b")) + }, + rhs: Parenthesized { + Infix("-", lhs: VariableExp("c"), rhs: VariableExp("d")) } - } + ) let generated = infix.generateCode() #expect(generated.contains("(a + b) * (c - d)")) @@ -69,11 +93,8 @@ internal struct EdgeCaseTestsExpressions { @Test("Return with complex expression generates correct syntax") internal func testReturnWithComplexExpression() throws { - let returnStmt = try Return { - try Infix("+") { - VariableExp("a") - VariableExp("b") - } + let returnStmt = Return { + Infix("+", lhs: VariableExp("a"), rhs: VariableExp("b")) } let generated = returnStmt.generateCode() diff --git a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsTypes.swift b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsTypes.swift index 1737ba65..97b5ee43 100644 --- a/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsTypes.swift +++ b/Tests/SyntaxKitTests/Unit/EdgeCases/EdgeCaseTestsTypes.swift @@ -1,3 +1,32 @@ +// +// EdgeCaseTestsTypes.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -31,7 +60,7 @@ internal struct EdgeCaseTestsTypes { @Test("Function with unnamed parameter generates correct syntax") internal func testFunctionWithUnnamedParameter() throws { let function = Function("process") { - Parameter(name: "data", type: "Data", isUnnamed: true) + Parameter(unlabeled: "data", type: "Data") } _: { Variable(.let, name: "result", type: "String", equals: "processed") } @@ -85,7 +114,7 @@ internal struct EdgeCaseTestsTypes { let computedProperty = ComputedProperty("description", type: "String") { Return { VariableExp("name").call("appending") { - ParameterExp(name: "", value: "\" - \" + String(count)") + ParameterExp(name: "", value: VariableExp("\" - \" + String(count)")) } } } diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchBasicTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchBasicTests.swift index f182781b..a2195ccf 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchBasicTests.swift @@ -1,3 +1,32 @@ +// +// CatchBasicTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchComplexTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchComplexTests.swift index c4849279..b1ff8f47 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchComplexTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchComplexTests.swift @@ -1,3 +1,32 @@ +// +// CatchComplexTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchEdgeCaseTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchEdgeCaseTests.swift index b7853308..40f503b2 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchEdgeCaseTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchEdgeCaseTests.swift @@ -1,3 +1,32 @@ +// +// CatchEdgeCaseTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchIntegrationTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchIntegrationTests.swift index 31d2ead6..2a5e4d5c 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchIntegrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/CatchIntegrationTests.swift @@ -1,3 +1,32 @@ +// +// CatchIntegrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoBasicTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoBasicTests.swift index e38dd7fa..11f85163 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoBasicTests.swift @@ -1,3 +1,32 @@ +// +// DoBasicTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoComplexTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoComplexTests.swift index c3faa800..78b0c216 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoComplexTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoComplexTests.swift @@ -1,3 +1,32 @@ +// +// DoComplexTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoEdgeCaseTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoEdgeCaseTests.swift index 2d3e6b1a..e2a4711d 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoEdgeCaseTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoEdgeCaseTests.swift @@ -1,3 +1,32 @@ +// +// DoEdgeCaseTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoIntegrationTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoIntegrationTests.swift index b4e80d0c..6e15be0e 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/DoIntegrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/DoIntegrationTests.swift @@ -1,3 +1,32 @@ +// +// DoIntegrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/ErrorHandlingTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/ErrorHandlingTests.swift index f50b972a..e231288f 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/ErrorHandlingTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/ErrorHandlingTests.swift @@ -1,3 +1,32 @@ +// +// ErrorHandlingTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowBasicTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowBasicTests.swift index fbfb0c45..ad422157 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowBasicTests.swift @@ -1,3 +1,32 @@ +// +// ThrowBasicTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowComplexTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowComplexTests.swift index 019df7d0..85422e2c 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowComplexTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowComplexTests.swift @@ -1,3 +1,32 @@ +// +// ThrowComplexTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowEdgeCaseTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowEdgeCaseTests.swift index a706277a..9addee14 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowEdgeCaseTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowEdgeCaseTests.swift @@ -1,3 +1,32 @@ +// +// ThrowEdgeCaseTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowFunctionTests.swift b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowFunctionTests.swift index 9efde18e..c2233fb5 100644 --- a/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowFunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/ErrorHandling/ThrowFunctionTests.swift @@ -1,3 +1,32 @@ +// +// ThrowFunctionTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Execution/BundleResolveLibPathTests.swift b/Tests/SyntaxKitTests/Unit/Execution/BundleResolveLibPathTests.swift new file mode 100644 index 00000000..c996318e --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/BundleResolveLibPathTests.swift @@ -0,0 +1,125 @@ +// +// BundleResolveLibPathTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// `Bundle.resolveLibPath` candidate handling plus the two bundle-relative +/// fallbacks (`/lib`, `/../lib/skit`), driven through the +/// injectable `executableURL` seam against fixture trees. +@Suite internal struct BundleResolveLibPathTests { + /// Creates a unique temp directory torn down by the returned cleanup closure. + private func makeTempDir() throws -> (url: URL, cleanup: () -> Void) { + let fileManager = FileManager.default + let url = fileManager.temporaryDirectory + .appendingPathComponent("resolvelib-\(UUID().uuidString)") + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + return (url, { try? FileManager.default.removeItem(at: url) }) + } + + /// Materializes `dir` as a SyntaxKit lib dir by dropping in the marker dylib + /// `isLibDir` looks for. + private func makeLibDir(at dir: URL) throws { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let marker = dir.appendingPathComponent("SyntaxKit".dylibFilename) + try Data().write(to: marker) + } + + @Test("A valid explicit candidate is returned verbatim") + internal func explicitCandidateReturned() throws { + let temp = try makeTempDir() + defer { temp.cleanup() } + let lib = temp.url.appendingPathComponent("lib") + try makeLibDir(at: lib) + + let resolved = try Bundle.main.resolveLibPath(candidates: [lib.path]) + #expect(resolved == lib.path) + } + + @Test("An explicit candidate that isn't a lib dir throws CLIError") + internal func badCandidateThrows() throws { + let temp = try makeTempDir() + defer { temp.cleanup() } + // Exists but has no marker dylib. + #expect(throws: CLIError.self) { + _ = try Bundle.main.resolveLibPath(candidates: [temp.url.path]) + } + } + + @Test("The adjacent /lib layout is found when no candidate matches") + internal func adjacentFallback() throws { + let temp = try makeTempDir() + defer { temp.cleanup() } + let binDir = temp.url.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try makeLibDir(at: binDir.appendingPathComponent("lib")) + + let resolved = try Bundle.main.resolveLibPath( + candidates: [nil], + executableURL: binDir.appendingPathComponent("skit") + ) + #expect(resolved.hasSuffix("/lib")) + #expect(FileManager.default.isLibDir(resolved)) + } + + @Test("The Homebrew /../lib/skit layout is found as a fallback") + internal func homebrewFallback() throws { + let temp = try makeTempDir() + defer { temp.cleanup() } + let binDir = temp.url.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + // Only the brew layout exists (no adjacent /lib), so the brew + // branch is what resolves. + try makeLibDir(at: temp.url.appendingPathComponent("lib/skit")) + + let resolved = try Bundle.main.resolveLibPath( + candidates: [nil], + executableURL: binDir.appendingPathComponent("skit") + ) + #expect(resolved.hasSuffix("/lib/skit")) + #expect(FileManager.default.isLibDir(resolved)) + } + + @Test("With no candidate and no fallback layout, resolveLibPath throws CLIError") + internal func nothingFoundThrows() throws { + let temp = try makeTempDir() + defer { temp.cleanup() } + let binDir = temp.url.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + + #expect(throws: CLIError.self) { + _ = try Bundle.main.resolveLibPath( + candidates: [nil], + executableURL: binDir.appendingPathComponent("skit") + ) + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift b/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift new file mode 100644 index 00000000..5e20f436 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/ContentHasherTests.swift @@ -0,0 +1,78 @@ +// +// ContentHasherTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct ContentHasherTests { + /// The FNV-1a offset basis, as the 16-char hex an empty hasher must finalize to. + private static let emptyDigest = "cbf29ce484222325" + + private func digest(of data: Data) -> String { + var hasher = ContentHasher() + hasher.update(data: data) + return hasher.finalize() + } + + @Test("Empty input finalizes to the FNV-1a offset basis") + internal func emptyInputDigest() { + let hasher = ContentHasher() + #expect(hasher.finalize() == Self.emptyDigest) + #expect(digest(of: Data()) == Self.emptyDigest) + } + + @Test("Same bytes produce the same digest across fresh hashers") + internal func determinism() { + let data = Data("the quick brown fox".utf8) + let first = digest(of: data) + let second = digest(of: data) + #expect(first == second) + } + + @Test("Different inputs produce different digests") + internal func distinctInputs() { + #expect(digest(of: Data("alpha".utf8)) != digest(of: Data("beta".utf8))) + } + + @Test("Byte order is significant") + internal func orderSensitivity() { + #expect(digest(of: Data([0x01, 0x02])) != digest(of: Data([0x02, 0x01]))) + } + + @Test("Chunked updates accumulate identically to a single update") + internal func chunkedAccumulation() { + var chunked = ContentHasher() + chunked.update(data: Data("AB".utf8)) + chunked.update(data: Data("C".utf8)) + + #expect(chunked.finalize() == digest(of: Data("ABC".utf8))) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift b/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift new file mode 100644 index 00000000..15143638 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/FileManagerFileSystemTests.swift @@ -0,0 +1,142 @@ +// +// FileManagerFileSystemTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct FileManagerFileSystemTests { + private let fileManager = FileManager.default + + /// Creates a fresh empty temp directory, runs `body` against it, and removes + /// it afterward. + private func withTempDir(_ body: (URL) throws -> Void) throws { + let dir = fileManager.temporaryDirectory + .appendingPathComponent("fs-\(UUID().uuidString)") + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: dir) } + try body(dir) + } + + // MARK: - pathKind + + @Test("pathKind classifies directory, file, and missing paths") + internal func pathKindClassifies() throws { + try withTempDir { dir in + #expect(fileManager.pathKind(atPath: dir.path) == .directory) + + let file = dir.appendingPathComponent("a.txt") + try Data("x".utf8).write(to: file) + #expect(fileManager.pathKind(atPath: file.path) == .file) + + #expect(fileManager.pathKind(atPath: dir.appendingPathComponent("nope").path) == .missing) + } + } + + // MARK: - fingerprint + + @Test("fingerprint reports byte size for an existing file") + internal func fingerprintReportsSize() throws { + try withTempDir { dir in + let file = dir.appendingPathComponent("a.bin") + try Data("hello".utf8).write(to: file) + + let fingerprint = try #require(fileManager.fingerprint(atPath: file.path)) + #expect(fingerprint.size == 5) + } + } + + @Test("fingerprint is nil for a missing file") + internal func fingerprintMissing() throws { + try withTempDir { dir in + #expect(fileManager.fingerprint(atPath: dir.appendingPathComponent("nope").path) == nil) + } + } + + // MARK: - regularFiles + + @Test("regularFiles walks recursively, skips hidden + directories, sorted by path") + internal func regularFilesWalk() throws { + try withTempDir { dir in + let sub = dir.appendingPathComponent("sub") + try fileManager.createDirectory(at: sub, withIntermediateDirectories: true) + try Data("1".utf8).write(to: dir.appendingPathComponent("b.swift")) + try Data("2".utf8).write(to: dir.appendingPathComponent("a.swift")) + try Data("3".utf8).write(to: sub.appendingPathComponent("c.swift")) + try Data("4".utf8).write(to: dir.appendingPathComponent(".hidden")) + + let files = try fileManager.regularFiles(under: dir) + let names = files.map(\.lastPathComponent) + + // The hidden file and the `sub` directory itself are excluded; nested + // files are included; the result is sorted by full path. + #expect(names == ["a.swift", "b.swift", "c.swift"]) + #expect(files == files.sorted { $0.path < $1.path }) + } + } + + @Test("regularFiles yields nothing for a missing directory") + internal func regularFilesMissingDir() throws { + try withTempDir { dir in + // `enumerator(at:)` returns an empty (non-nil) enumerator for a missing + // directory rather than nil, so the walk yields an empty list rather than + // throwing — matching the directory-mode behavior of treating an empty + // input set as a no-op. + let missing = dir.appendingPathComponent("does-not-exist") + let files = try fileManager.regularFiles(under: missing) + #expect(files.isEmpty) + } + } + + // MARK: - writeData + + @Test("writeData creates intermediate directories") + internal func writeDataCreatesIntermediates() throws { + try withTempDir { dir in + let destination = dir.appendingPathComponent("a/b/c/out.txt") + try fileManager.writeData(Data("payload".utf8), to: destination) + + #expect(fileManager.pathKind(atPath: destination.path) == .file) + #expect(try Data(contentsOf: destination) == Data("payload".utf8)) + } + } + + // MARK: - URL.rerooted + + @Test("rerooted preserves the relative subpath under a new base") + internal func rerootedPreservesSubpath() { + let input = URL(fileURLWithPath: "/in/sub/a.swift") + let rerooted = input.rerooted( + from: URL(fileURLWithPath: "/in"), + onto: URL(fileURLWithPath: "/out") + ) + #expect(rerooted.path == "/out/sub/a.swift") + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift b/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift new file mode 100644 index 00000000..1b1131ae --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/OutputCacheTests.swift @@ -0,0 +1,151 @@ +// +// OutputCacheTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct OutputCacheTests { + /// A value-type `EnvironmentProvider` whose environment is fixed, so + /// cache-key derivation can be tested without depending on the real process + /// environment. A value type rather than a `ProcessInfo` subclass because + /// `swift-corelibs-foundation` (Linux/Windows) declares `ProcessInfo` as + /// `final` with `environment` on an extension — subclass-and-override + /// doesn't compile there. + private struct FakeEnvironment: EnvironmentProvider { + let environment: [String: String] + let processIdentifier: Int32 = 1 + } + + /// A stub `ContentHashing` that ignores its input and returns a fixed digest, + /// used to prove `OutputCache` derives keys through the injected factory. + private struct StubHasher: ContentHashing { + private static let fixedDigest = "stub-digest" + mutating func update(data: Data) {} + func finalize() -> String { Self.fixedDigest } + } + + private static let libPath = "/nonexistent/lib" + + private func cache( + swiftVersion: String? = "swift 6.1", + environment: [String: String] = [:] + ) -> OutputCache { + OutputCache( + swiftVersion: swiftVersion, + environmentProvider: FakeEnvironment(environment: environment) + ) + } + + private func key(_ cache: OutputCache, source: String = "Struct(\"Foo\") {}") -> String { + cache.key(forInput: source, libPath: Self.libPath) + } + + @Test("key() derives the digest through the injected hasher factory") + internal func usesInjectedHasher() { + let cache = OutputCache( + swiftVersion: "swift 6.1", + environmentProvider: FakeEnvironment(environment: [:]), + makeHasher: { StubHasher() } + ) + #expect(cache.key(forInput: "anything", libPath: Self.libPath) == "stub-digest") + } + + @Test("The same inputs always derive the same key") + internal func stableKey() { + let cache = cache() + let firstKey = key(cache) + let secondKey = key(cache) + #expect(firstKey == secondKey) + } + + @Test("Different source bytes derive different keys") + internal func sourceSensitivity() { + let cache = cache() + #expect(key(cache, source: "Struct(\"A\") {}") != key(cache, source: "Struct(\"B\") {}")) + } + + @Test("A different swift version derives a different key") + internal func swiftVersionSensitivity() { + #expect(key(cache(swiftVersion: "swift 6.1")) != key(cache(swiftVersion: "swift 6.2"))) + } + + @Test("SKIT_/SYNTAXKIT_ env vars are mixed into the key") + internal func relevantEnvVarsChangeKey() { + let base = key(cache(environment: [:])) + #expect(key(cache(environment: ["SKIT_FOO": "1"])) != base) + #expect(key(cache(environment: ["SYNTAXKIT_BAR": "1"])) != base) + } + + @Test("Unrelated env vars are ignored by the key") + internal func irrelevantEnvVarsIgnored() { + let base = key(cache(environment: [:])) + #expect(key(cache(environment: ["PATH": "/usr/bin", "HOME": "/root"])) == base) + } + + @Test("store then lookup round-trips the rendered payload") + internal func storeLookupRoundTrip() throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("outputcache-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + // XDG_CACHE_HOME redirects the cache root; it isn't a SKIT_/SYNTAXKIT_ var, + // so it doesn't affect the key itself. + let cache = cache(environment: ["XDG_CACHE_HOME": cacheRoot.path]) + let key = key(cache) + let payload = Data("rendered output".utf8) + + #expect(cache.lookup(key: key) == nil) + try cache.store(key: key, data: payload) + #expect(cache.lookup(key: key) == payload) + } + + @Test("clear() removes stored entries and is a no-op on an empty cache") + internal func clearRemovesEntries() throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("outputcache-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + let cache = cache(environment: ["XDG_CACHE_HOME": cacheRoot.path]) + let key = key(cache) + + // No-op before anything is written. + try cache.clear() + + try cache.store(key: key, data: Data("rendered output".utf8)) + #expect(cache.lookup(key: key) != nil) + + try cache.clear() + #expect(cache.lookup(key: key) == nil) + + // Idempotent: clearing an already-empty cache still doesn't throw. + try cache.clear() + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunInputTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunInputTests.swift new file mode 100644 index 00000000..7dd47fc5 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunInputTests.swift @@ -0,0 +1,107 @@ +// +// RunInputTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// `RunInput.resolve` is the sole producer of `RunError.invalidInput`: it stats +/// the path and enforces the per-mode output rules. +@Suite internal struct RunInputTests { + @Test("A file path resolves to .singleFile, carrying the output through") + internal func existingFileIsSingleFile() throws { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("runinput-\(UUID().uuidString).swift") + try Data("let x = 1\n".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let resolved = try RunInput.resolve(input: file.path, output: "/out.swift") + guard case .singleFile(let inputPath, let outputPath) = resolved else { + Issue.record("expected .singleFile, got \(resolved)") + return + } + #expect(inputPath == file.path) + #expect(outputPath == "/out.swift") + } + + @Test("A directory with an output resolves to .directory") + internal func directoryWithOutput() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("runinput-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let resolved = try RunInput.resolve(input: dir.path, output: "/out") + guard case .directory(let inputDir, let outputDir) = resolved else { + Issue.record("expected .directory, got \(resolved)") + return + } + #expect(inputDir == dir.path) + #expect(outputDir == "/out") + } + + @Test("A non-existent path throws RunError.invalidInput") + internal func missingPathIsInvalidInput() { + let missing = "/no/such/path-\(UUID().uuidString).swift" + do { + _ = try RunInput.resolve(input: missing, output: nil) + Issue.record("expected resolve to throw") + } catch let error as RunError { + guard case .invalidInput(let message) = error else { + Issue.record("expected .invalidInput, got \(error)") + return + } + #expect(message.contains("does not exist")) + } catch { + Issue.record("expected RunError, got \(error)") + } + } + + @Test("A directory input without an output throws RunError.invalidInput") + internal func directoryWithoutOutputIsInvalidInput() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("runinput-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + do { + _ = try RunInput.resolve(input: dir.path, output: nil) + Issue.record("expected resolve to throw") + } catch let error as RunError { + guard case .invalidInput(let message) = error else { + Issue.record("expected .invalidInput, got \(error)") + return + } + #expect(message.contains("-o")) + } catch { + Issue.record("expected RunError, got \(error)") + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift new file mode 100644 index 00000000..c9dc2ca5 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunnerBatchRenderTests.swift @@ -0,0 +1,149 @@ +// +// RunnerBatchRenderTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// Exercises the filesystem-free batch entry point `Runner.render(sources:)`: +/// it reads nothing and writes nothing, returning a `FileOutcome` per input +/// carrying the rendered `stdout` (or a `RunError`). Disabled on WASI because +/// the internal temp-wrapper write the render still performs is unreliable +/// there (see `RunnerRenderFailureTests`). +@Suite internal struct RunnerBatchRenderTests { + /// Marker a source carries to make the stub backend fail that input. + private static let failMarker = "// FAIL" + + /// A runner whose stub backend reads the wrapped program and reports a + /// non-zero exit for any input whose source contains `failMarker`, and exit 0 + /// (with a fixed rendered payload) otherwise — so a single call can mix + /// successes and failures without spawning `swift`. + private func runner(toolchain: ToolchainVerification = .verified) -> Runner { + Runner( + libPath: "/does/not/matter", + cache: nil, + timeoutSeconds: 0, + toolchainVerification: toolchain + ) { invocation in + let wrapped = (try? String(contentsOfFile: invocation.wrappedPath, encoding: .utf8)) ?? "" + if wrapped.contains(Self.failMarker) { + return .completed(ProcessResult(exitCode: 1, stdout: Data(), stderr: "boom\n")) + } + return .completed(ProcessResult(exitCode: 0, stdout: Data("RENDERED".utf8), stderr: "")) + } + } + + private func input(_ name: String, _ source: String) -> RenderInput { + RenderInput(url: URL(fileURLWithPath: "/in/\(name)"), source: source) + } + + @Test("An empty input set yields no outcomes without touching the backend") + internal func emptyYieldsEmpty() async { + let outcomes = await runner().render(sources: []) + #expect(outcomes.isEmpty) + } + + @Test( + "Every successful input returns its rendered stdout with a nil result", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func successesCarryStdout() async { + let outcomes = await runner().render(sources: [ + input("a.swift", "let a = 1\n"), + input("b.swift", "let b = 2\n"), + ]) + + #expect(outcomes.count == 2) + #expect(outcomes.failureCount == 0) + for outcome in outcomes { + #expect(outcome.result == nil) + #expect(outcome.stdout == Data("RENDERED".utf8)) + } + // The input URLs are preserved so the caller can reroot each output. + #expect(Set(outcomes.map(\.input.lastPathComponent)) == ["a.swift", "b.swift"]) + } + + @Test( + "A failing input is isolated: peers still render, failureCount reflects it", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func failureIsIsolated() async { + let outcomes = await runner().render(sources: [ + input("ok.swift", "let ok = 1\n"), + input("bad.swift", "\(Self.failMarker)\nlet bad = 2\n"), + ]) + + let byName = Dictionary( + uniqueKeysWithValues: outcomes.map { ($0.input.lastPathComponent, $0) } + ) + + #expect(outcomes.count == 2) + #expect(outcomes.failureCount == 1) + + let okOutcome = try? #require(byName["ok.swift"]) + #expect(okOutcome?.result == nil) + #expect(okOutcome?.stdout == Data("RENDERED".utf8)) + + let badOutcome = try? #require(byName["bad.swift"]) + #expect(badOutcome?.stdout.isEmpty == true) + guard case .renderFailed(let exitCode, let stderr, let toolchain)? = badOutcome?.result else { + Issue.record("expected .renderFailed for the marked input") + return + } + #expect(exitCode == 1) + #expect(stderr == "boom\n") + #expect(toolchain == .verified) + } + + @Test( + "When every input fails, all outcomes are still collected (no short-circuit)", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func allInputsFailAreAllCollected() async { + let outcomes = await runner().render(sources: [ + input("a.swift", "\(Self.failMarker)\nlet a = 1\n"), + input("b.swift", "\(Self.failMarker)\nlet b = 2\n"), + input("c.swift", "\(Self.failMarker)\nlet c = 3\n"), + ]) + + // The batch must not bail on the first failure: every input gets an outcome, + // and each carries its own .renderFailed. + #expect(outcomes.count == 3) + #expect(outcomes.failureCount == 3) + for outcome in outcomes { + #expect(outcome.stdout.isEmpty) + guard case .renderFailed? = outcome.result else { + Issue.record("expected .renderFailed for \(outcome.input.lastPathComponent)") + continue + } + } + #expect(Set(outcomes.map(\.input.lastPathComponent)) == ["a.swift", "b.swift", "c.swift"]) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift new file mode 100644 index 00000000..e6aac682 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderFailureTests.swift @@ -0,0 +1,100 @@ +// +// RunnerRenderFailureTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct RunnerRenderFailureTests { + /// Runs `render(source:originalPath:)` against in-memory source with a stub + /// backend that always reports a non-zero exit, so the render fails + /// deterministically without spawning `swift`. Returns the thrown `RunError`, + /// or nil if the call unexpectedly succeeded. + private func renderFailure( + toolchain: ToolchainVerification + ) async -> RunError? { + let runner = Runner( + libPath: "/does/not/matter", + cache: nil, + timeoutSeconds: 0, + toolchainVerification: toolchain + ) { _ in + .completed(ProcessResult(exitCode: 1, stdout: Data(), stderr: "boom\n")) + } + + do { + // Omit `originalPath` — exercises the optional anonymous-snippet path. + _ = try await runner.render(source: "let x = 1\n") + return nil + } catch { + return error + } + } + + @Test( + "renderFailed carries the session's toolchain verification and diagnostics", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func carriesUnverifiedToolchain() async { + guard + case .renderFailed(let exitCode, let stderr, let toolchain)? = + await renderFailure(toolchain: .unverified) + else { + Issue.record("expected .renderFailed") + return + } + #expect(exitCode == 1) + #expect(stderr == "boom\n") + #expect(toolchain == .unverified) + } + + @Test( + "renderFailed reflects a verified toolchain unchanged", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func reflectsVerifiedToolchain() async { + guard + case .renderFailed(_, _, let toolchain)? = + await renderFailure(toolchain: .verified) + else { + Issue.record("expected .renderFailed") + return + } + #expect(toolchain == .verified) + } + + @Test("A directly-constructed Runner defaults to notChecked") + internal func defaultsToNotChecked() { + let runner = Runner(libPath: "/x", cache: nil, timeoutSeconds: 0) { _ in + .completed(ProcessResult(exitCode: 0, stdout: Data(), stderr: "")) + } + #expect(runner.toolchainVerification == .notChecked) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderTests.swift b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderTests.swift new file mode 100644 index 00000000..04f5a4ef --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/RunnerRenderTests.swift @@ -0,0 +1,108 @@ +// +// RunnerRenderTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// Single-file `render(source:originalPath:)` behaviour beyond the +/// `.renderFailed` path (covered by `RunnerRenderFailureTests`): the success +/// branch, the `.unexpected` wrapper, and the stderr path rewrite. +@Suite internal struct RunnerRenderTests { + /// An error the stub backend throws to exercise the `.unexpected` wrapper. + private struct BackendBoom: Error {} + + @Test( + "A zero-exit render returns the backend's stdout as the rendered bytes", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func successReturnsStdout() async throws { + let runner = Runner(libPath: "/x", cache: nil, timeoutSeconds: 0) { _ in + .completed(ProcessResult(exitCode: 0, stdout: Data("rendered".utf8), stderr: "")) + } + let result = try await runner.render(source: "let x = 1\n") + #expect(result.stdout == Data("rendered".utf8)) + #expect(result.stderr.isEmpty) + } + + @Test( + "A non-RunError thrown by the backend is wrapped as RunError.unexpected", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func backendErrorBecomesUnexpected() async { + let runner = Runner(libPath: "/x", cache: nil, timeoutSeconds: 0) { _ in + throw BackendBoom() + } + do { + _ = try await runner.render(source: "let x = 1\n") + Issue.record("expected render to throw") + } catch let error as RunError { + guard case .unexpected(let underlying) = error else { + Issue.record("expected .unexpected, got \(error)") + return + } + #expect(underlying is BackendBoom) + } catch { + Issue.record("expected RunError, got \(error)") + } + } + + @Test( + "The wrapper temp path in stderr is rewritten back to the original input path", + .disabled(if: Platform.isWASI, "Render needs host filesystem/subprocess; not on WASI") + ) + internal func rewritesWrapperPathInStderr() async { + // The stub echoes the invocation's wrapped-file path in stderr; the runner + // must rewrite it back to the caller's originalPath before surfacing it. + let runner = Runner(libPath: "/x", cache: nil, timeoutSeconds: 0) { invocation in + .completed( + ProcessResult( + exitCode: 1, + stdout: Data(), + stderr: "\(invocation.wrappedPath):3:1: error: bad\n" + ) + ) + } + do { + _ = try await runner.render(source: "let x = 1\n", originalPath: "/work/in.swift") + Issue.record("expected render to throw") + } catch let error as RunError { + guard case .renderFailed(_, let stderr, _) = error else { + Issue.record("expected .renderFailed, got \(error)") + return + } + #expect(stderr.contains("/work/in.swift")) + // The wrapper filename must be gone — proof the rewrite replaced the path. + #expect(!stderr.contains("Input.wrapped.swift")) + } catch { + Issue.record("expected RunError, got \(error)") + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/TaskTimeoutTests.swift b/Tests/SyntaxKitTests/Unit/Execution/TaskTimeoutTests.swift new file mode 100644 index 00000000..16c7f312 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/TaskTimeoutTests.swift @@ -0,0 +1,68 @@ +// +// TaskTimeoutTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +/// Direct coverage for `Task.timeout(seconds:operation:)`: the operation-wins, +/// operation-throws, and watchdog-wins races. +@Suite internal struct TaskTimeoutTests { + /// An error the operation throws to prove errors propagate out of the race. + private struct OperationBoom: Error {} + + @Test("An operation that finishes before the deadline returns its value") + internal func operationWinsReturnsValue() async throws { + let value = try await Task.timeout(seconds: 60) { 42 } + #expect(value == 42) + } + + @Test("An operation that throws propagates the error out of the race") + internal func operationThrowsPropagates() async { + await #expect(throws: OperationBoom.self) { + _ = try await Task.timeout(seconds: 60) { + throw OperationBoom() + } + } + } + + @Test( + "When the watchdog wins, timeout returns nil", + .disabled(if: Platform.isWASI, "No concurrency runtime / Task.sleep on WASI") + ) + internal func watchdogWinsReturnsNil() async throws { + // Operation sleeps well past the 1s deadline, so the watchdog fires first. + let value = try await Task.timeout(seconds: 1) { () -> Int in + try await Task.sleep(nanoseconds: 30 * 1_000_000_000) + return 42 + } + #expect(value == nil) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift b/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift new file mode 100644 index 00000000..d3adc966 --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/ToolchainCheckResultTests.swift @@ -0,0 +1,111 @@ +// +// ToolchainCheckResultTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct ToolchainCheckResultTests { + private static let stampFilename = "swift-version.txt" + + /// Creates a fresh temp lib dir, optionally seeded with a `swift-version.txt` + /// stamp, runs `body` against its path, and removes the dir afterward. + private func withLibDir( + stamp: String?, + _ body: (String) throws -> Void + ) throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("toolchain-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + if let stamp { + try Data(stamp.utf8).write(to: dir.appendingPathComponent(Self.stampFilename)) + } + try body(dir.path) + } + + @Test("Identical bundle/local versions match") + internal func match() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1") + guard case .match = result else { + Issue.record("expected .match, got \(result)") + return + } + } + } + + @Test("Trailing whitespace and newlines are normalized before comparison") + internal func normalizesWhitespace() throws { + try withLibDir(stamp: "swift 6.1\n ") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1\n") + guard case .match = result else { + Issue.record("expected .match, got \(result)") + return + } + } + } + + @Test("Differing versions report a mismatch carrying both strings") + internal func mismatch() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.2") + guard case .mismatch(let bundle, let local) = result else { + Issue.record("expected .mismatch, got \(result)") + return + } + #expect(bundle == "swift 6.1") + #expect(local == "swift 6.2") + } + } + + @Test("A missing stamp file yields stampMissing") + internal func missingStamp() throws { + try withLibDir(stamp: nil) { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: "swift 6.1") + guard case .stampMissing = result else { + Issue.record("expected .stampMissing, got \(result)") + return + } + } + } + + @Test("A nil local version yields stampMissing") + internal func nilLocalVersion() throws { + try withLibDir(stamp: "swift 6.1") { libPath in + let result = ToolchainCheckResult(libPath: libPath, swiftVersion: nil) + guard case .stampMissing = result else { + Issue.record("expected .stampMissing, got \(result)") + return + } + } + } +} diff --git a/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift b/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift new file mode 100644 index 00000000..872d137d --- /dev/null +++ b/Tests/SyntaxKitTests/Unit/Execution/WrappedSourceTests.swift @@ -0,0 +1,117 @@ +// +// WrappedSourceTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import SyntaxKit + +@Suite internal struct WrappedSourceTests { + private static let path = "/tmp/input.swift" + + private func rendered(_ source: String) -> String { + WrappedSource(source: source, originalPath: Self.path).rendered + } + + /// The wrapper always opens with the SyntaxKit import and emits the + /// `Group { … }` + `print(...)` scaffold, regardless of input. + private func expectScaffold(_ rendered: String) { + #expect(rendered.contains("import SyntaxKit")) + #expect(rendered.contains("let __skit_root = Group {")) + #expect(rendered.contains("print(__skit_root.generateCode())")) + #expect(rendered.contains("#sourceLocation(file: \"\(Self.path)\"")) + } + + @Test("No-import source is left in the body, starting at line 1") + internal func noImports() { + let out = rendered("Struct(\"Foo\") {}") + expectScaffold(out) + #expect(out.contains("Struct(\"Foo\") {}")) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Leading imports are hoisted above the wrapper body") + internal func importsOnly() { + let out = rendered("import Foundation\nimport SwiftSyntax\n") + expectScaffold(out) + #expect(out.contains("import Foundation")) + #expect(out.contains("import SwiftSyntax")) + // Imports-only input has no body, so the fence starts at line 1. + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Imports are hoisted and the body fence reports the body's line") + internal func mixedImportsAndBody() { + let out = rendered("import Foundation\nlet x = 1\n") + expectScaffold(out) + #expect(out.contains("import Foundation")) + #expect(out.contains("let x = 1")) + // The body slice starts at the newline that is leading trivia of `let`, so + // the fence reports line 1 and the retained leading newline keeps `let x = 1` + // aligned to its original line 2. + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + + // The hoisted import must sit ahead of the `Group {` scaffold, not inside it. + let importIndex = try? #require(out.range(of: "import Foundation")) + let groupIndex = try? #require(out.range(of: "let __skit_root = Group {")) + if let importIndex, let groupIndex { + #expect(importIndex.lowerBound < groupIndex.lowerBound) + } + } + + @Test("A comment before a non-import statement stays in the body") + internal func leadingCommentBeforeBody() { + let out = rendered("// note\nlet x = 1\n") + expectScaffold(out) + // The comment is leading trivia of the first (non-import) statement, so the + // body slice — and thus the rendered output — retains it. + #expect(out.contains("// note")) + #expect(out.contains("let x = 1")) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Empty source still renders the scaffold") + internal func emptySource() { + let out = rendered("") + expectScaffold(out) + #expect(out.contains("#sourceLocation(file: \"\(Self.path)\", line: 1)")) + } + + @Test("Backslashes and quotes in the path are escaped in #sourceLocation") + internal func escapesPathSpecials() { + // A path with a literal quote and backslash would otherwise produce a + // syntactically invalid #sourceLocation string literal. + let out = WrappedSource( + source: "Struct(\"Foo\") {}", + originalPath: #"/tmp/a"b\c.swift"# + ).rendered + // " → \" and \ → \\, so the rendered fence carries the escaped form. + #expect(out.contains(#"#sourceLocation(file: "/tmp/a\"b\\c.swift""#)) + } +} diff --git a/Tests/SyntaxKitTests/Unit/Expressions/CallTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/CallTests.swift index 415df158..f04e5a9f 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/CallTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/CallTests.swift @@ -1,13 +1,13 @@ // // CallTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -42,7 +42,7 @@ import Testing @Test("Call with string parameter generates correct syntax") internal func testCallWithStringParameter() throws { let call = Call("print") { - ParameterExp(name: "", value: "\"Hello, World!\"") + ParameterExp(name: "", value: VariableExp("\"Hello, World!\"")) } let generated = call.generateCode() #expect(generated.contains("print(\"Hello, World!\")")) @@ -51,7 +51,7 @@ import Testing @Test("Call with named parameter generates correct syntax") internal func testCallWithNamedParameter() throws { let call = Call("function") { - ParameterExp(name: "value", value: "42") + ParameterExp(name: "value", value: VariableExp("42")) } let generated = call.generateCode() #expect(generated.contains("function(value:42)")) @@ -60,8 +60,8 @@ import Testing @Test("Call with multiple parameters generates correct syntax") internal func testCallWithMultipleParameters() throws { let call = Call("print") { - ParameterExp(name: "", value: "\"Count:\"") - ParameterExp(name: "count", value: "5") + ParameterExp(name: "", value: VariableExp("\"Count:\"")) + ParameterExp(name: "count", value: VariableExp("5")) } let generated = call.generateCode() #expect(generated.contains("print(\"Count:\", count:5)")) @@ -70,7 +70,7 @@ import Testing @Test("Call with string interpolation generates correct syntax") internal func testCallWithStringInterpolation() throws { let call = Call("print") { - ParameterExp(name: "", value: "\"Starting \\(brand) vehicle...\"") + ParameterExp(name: "", value: VariableExp("\"Starting \\(brand) vehicle...\"")) } let generated = call.generateCode() #expect(generated.contains("print(\"Starting \\(brand) vehicle...\")")) @@ -80,7 +80,7 @@ import Testing internal func testCallInFunctionBody() throws { let function = Function("test") { Call("print") { - ParameterExp(name: "", value: "\"Hello\"") + ParameterExp(name: "", value: VariableExp("\"Hello\"")) } } let generated = function.generateCode() @@ -93,7 +93,7 @@ import Testing let extSyntax = Extension("Vehicle") { Function("start") { Call("print") { - ParameterExp(name: "", value: "\"Starting \\(brand) vehicle...\"") + ParameterExp(name: "", value: VariableExp("\"Starting \\(brand) vehicle...\"")) } } } @@ -108,7 +108,7 @@ import Testing let structExp = Struct("Car") { Function("start") { Call("print") { - ParameterExp(name: "", value: "\"Starting \\(brand) car engine...\"") + ParameterExp(name: "", value: VariableExp("\"Starting \\(brand) car engine...\"")) } } } diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ClosureCaptureCoverageTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ClosureCaptureCoverageTests.swift index a6e1a1e3..852c2194 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ClosureCaptureCoverageTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ClosureCaptureCoverageTests.swift @@ -1,13 +1,13 @@ // // ClosureCaptureCoverageTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ClosureCoverageTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ClosureCoverageTests.swift index 2d2f443a..ee861861 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ClosureCoverageTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ClosureCoverageTests.swift @@ -1,13 +1,13 @@ // // ClosureCoverageTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -76,7 +76,7 @@ internal final class ClosureCoverageTests { @Test("Build parameter expression item") internal func testBuildParameterExpressionItem() { // Test the buildParameterExpressionItem method - let paramExp = ParameterExp(name: "test", value: "value") + let paramExp = ParameterExp(name: "test", value: VariableExp("value")) let closure = Closure(body: { paramExp }) @@ -126,7 +126,7 @@ internal final class ClosureCoverageTests { @Test("Build parameter expression item with param expr syntax") internal func testBuildParameterExpressionItemWithParamExprSyntax() { // Test ParameterExp with parameter expression syntax - let paramExp = ParameterExp(name: "test", value: "value") + let paramExp = ParameterExp(name: "test", value: VariableExp("value")) let closure = Closure(body: { paramExp }) diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpBasicTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpBasicTests.swift index 7f5e47b8..45b04875 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpBasicTests.swift @@ -1,13 +1,13 @@ // // ConditionalOpBasicTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpComplexTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpComplexTests.swift index 7eec2ffd..2e213dc9 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpComplexTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpComplexTests.swift @@ -1,13 +1,13 @@ // // ConditionalOpComplexTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpLiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpLiteralTests.swift index 596d328e..7267d272 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpLiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ConditionalOp/ConditionalOpLiteralTests.swift @@ -1,13 +1,13 @@ // // ConditionalOpLiteralTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/LiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/LiteralTests.swift index 9484f253..27f31816 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/LiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/LiteralTests.swift @@ -1,3 +1,32 @@ +// +// LiteralTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Expressions/LiteralValueTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/LiteralValueTests.swift index 2a9914b9..396129cd 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/LiteralValueTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/LiteralValueTests.swift @@ -1,13 +1,13 @@ // // LiteralValueTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpBasicTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpBasicTests.swift index 5db808bd..f2f1d9b5 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpBasicTests.swift @@ -1,13 +1,13 @@ // // NegatedPropertyAccessExpBasicTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpFunctionTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpFunctionTests.swift index 84f26016..f1a7373b 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpFunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpFunctionTests.swift @@ -1,13 +1,13 @@ // // NegatedPropertyAccessExpFunctionTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpLiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpLiteralTests.swift index b9be7ec3..387e2e80 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpLiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpLiteralTests.swift @@ -1,13 +1,13 @@ // // NegatedPropertyAccessExpLiteralTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpPropertyTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpPropertyTests.swift index 41fa5541..cdf655dc 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpPropertyTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/NegatedPropertyAccessExp/NegatedPropertyAccessExpPropertyTests.swift @@ -1,13 +1,13 @@ // // NegatedPropertyAccessExpPropertyTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingBasicTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingBasicTests.swift index 74f966a2..0fc1413c 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingBasicTests.swift @@ -1,13 +1,13 @@ // // OptionalChainingBasicTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingLiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingLiteralTests.swift index 5844b504..65757aa8 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingLiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingLiteralTests.swift @@ -1,13 +1,13 @@ // // OptionalChainingLiteralTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingOperatorTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingOperatorTests.swift index f73dc7d2..7dff7f36 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingOperatorTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingOperatorTests.swift @@ -1,13 +1,13 @@ // // OptionalChainingOperatorTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingPropertyTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingPropertyTests.swift index db179e2f..f642edf8 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingPropertyTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/OptionalChaining/OptionalChainingPropertyTests.swift @@ -1,13 +1,13 @@ // // OptionalChainingPropertyTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignBasicTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignBasicTests.swift index 7fb61f99..2f08c0b7 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignBasicTests.swift @@ -1,13 +1,13 @@ // // PlusAssignBasicTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -40,6 +40,7 @@ import Testing internal final class PlusAssignBasicTests { /// Tests basic plus assignment expression. @Test("Basic plus assignment expression generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testBasicPlusAssign() { let plusAssign = PlusAssign("count", 1) @@ -51,6 +52,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with variable and literal value. @Test("Plus assignment with variable and literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithVariableAndLiteralValue() { let plusAssign = PlusAssign("total", 42) @@ -62,6 +64,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with function call value. @Test("Plus assignment with function call value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithFunctionCallValue() { let plusAssign = PlusAssign("total", 50) @@ -73,6 +76,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with complex expression value. @Test("Plus assignment with complex expression value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithComplexExpressionValue() { let plusAssign = PlusAssign("score", 55) @@ -84,6 +88,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with conditional expression value. @Test("Plus assignment with conditional expression value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithConditionalExpressionValue() { let plusAssign = PlusAssign("total", 60) @@ -95,6 +100,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with closure expression value. @Test("Plus assignment with closure expression value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithClosureExpressionValue() { let plusAssign = PlusAssign("sum", 65) @@ -106,6 +112,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with array literal value. @Test("Plus assignment with array literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithArrayLiteralValue() { let plusAssign = PlusAssign("list", 70) @@ -117,6 +124,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with dictionary literal value. @Test("Plus assignment with dictionary literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithDictionaryLiteralValue() { let plusAssign = PlusAssign("dict", 75) @@ -128,6 +136,7 @@ internal final class PlusAssignBasicTests { /// Tests plus assignment with tuple literal value. @Test("Plus assignment with tuple literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithTupleLiteralValue() { let plusAssign = PlusAssign("tuple", 80) diff --git a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignLiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignLiteralTests.swift index 0b1296d3..835ee7c2 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignLiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignLiteralTests.swift @@ -1,13 +1,13 @@ // // PlusAssignLiteralTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -40,6 +40,7 @@ import Testing internal final class PlusAssignLiteralTests { /// Tests plus assignment with string literal value. @Test("Plus assignment with string literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithStringLiteralValue() { let plusAssign = PlusAssign("message", "Hello") @@ -51,6 +52,7 @@ internal final class PlusAssignLiteralTests { /// Tests plus assignment with numeric literal value. @Test("Plus assignment with numeric literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithNumericLiteralValue() { let plusAssign = PlusAssign("count", 42) @@ -62,6 +64,7 @@ internal final class PlusAssignLiteralTests { /// Tests plus assignment with boolean literal value. @Test("Plus assignment with boolean literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithBooleanLiteralValue() { let plusAssign = PlusAssign("flags", true) @@ -73,6 +76,7 @@ internal final class PlusAssignLiteralTests { /// Tests plus assignment with float literal value. @Test("Plus assignment with float literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithFloatLiteralValue() { let plusAssign = PlusAssign("value", 3.14) diff --git a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignPropertyTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignPropertyTests.swift index 83623ce9..b1cb97a8 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignPropertyTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignPropertyTests.swift @@ -1,13 +1,13 @@ // // PlusAssignPropertyTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -40,6 +40,7 @@ import Testing internal final class PlusAssignPropertyTests { /// Tests plus assignment with property access variable. @Test("Plus assignment with property access variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithPropertyAccessVariable() { let plusAssign = PlusAssign("user.score", 10) @@ -51,6 +52,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with complex variable expression. @Test("Plus assignment with complex variable expression generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithComplexVariableExpression() { let plusAssign = PlusAssign("getCurrentUser().score", 5) @@ -62,6 +64,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with nested property access variable. @Test("Plus assignment with nested property access variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithNestedPropertyAccessVariable() { let plusAssign = PlusAssign("user.profile.score", 15) @@ -73,6 +76,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with array element variable. @Test("Plus assignment with array element variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithArrayElementVariable() { let plusAssign = PlusAssign("scores[0]", 20) @@ -84,6 +88,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with dictionary element variable. @Test("Plus assignment with dictionary element variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithDictionaryElementVariable() { let plusAssign = PlusAssign("scores[\"player1\"]", 25) @@ -95,6 +100,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with tuple element variable. @Test("Plus assignment with tuple element variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithTupleElementVariable() { let plusAssign = PlusAssign("stats.0", 30) @@ -106,6 +112,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with computed property variable. @Test("Plus assignment with computed property variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithComputedPropertyVariable() { let plusAssign = PlusAssign("self.totalScore", 35) @@ -117,6 +124,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with static property variable. @Test("Plus assignment with static property variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithStaticPropertyVariable() { let plusAssign = PlusAssign("GameManager.totalScore", 40) @@ -128,6 +136,7 @@ internal final class PlusAssignPropertyTests { /// Tests plus assignment with enum case variable. @Test("Plus assignment with enum case variable generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithEnumCaseVariable() { let plusAssign = PlusAssign("ScoreType.bonus", 45) diff --git a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignSpecialValueTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignSpecialValueTests.swift index cea8a543..15f96df9 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignSpecialValueTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/PlusAssign/PlusAssignSpecialValueTests.swift @@ -1,13 +1,13 @@ // // PlusAssignSpecialValueTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -40,6 +40,7 @@ import Testing internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with nil literal value. @Test("Plus assignment with nil literal value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithNilLiteralValue() { let plusAssign = PlusAssign("optional", Literal.nil) @@ -51,6 +52,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with negative integer value. @Test("Plus assignment with negative integer value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithNegativeIntegerValue() { let plusAssign = PlusAssign("count", -5) @@ -62,6 +64,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with zero value. @Test("Plus assignment with zero value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithZeroValue() { let plusAssign = PlusAssign("total", 0) @@ -73,6 +76,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with large integer value. @Test("Plus assignment with large integer value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithLargeIntegerValue() { let plusAssign = PlusAssign("score", 1_000_000) @@ -84,6 +88,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with empty string value. @Test("Plus assignment with empty string value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithEmptyStringValue() { let plusAssign = PlusAssign("text", "") @@ -95,6 +100,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with special characters in string value. @Test("Plus assignment with special characters in string value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithSpecialCharactersInStringValue() { let plusAssign = PlusAssign("message", "Hello\nWorld\t!") @@ -106,6 +112,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with unicode characters in string value. @Test("Plus assignment with unicode characters in string value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithUnicodeCharactersInStringValue() { let plusAssign = PlusAssign("text", "café") @@ -117,6 +124,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with emoji in string value. @Test("Plus assignment with emoji in string value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithEmojiInStringValue() { let plusAssign = PlusAssign("message", "Hello 👋") @@ -128,6 +136,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with scientific notation float value. @Test("Plus assignment with scientific notation float value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithScientificNotationFloatValue() { let plusAssign = PlusAssign("value", 1.23e-4) @@ -139,6 +148,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with infinity float value. @Test("Plus assignment with infinity float value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithInfinityFloatValue() { let plusAssign = PlusAssign("value", Double.infinity) @@ -150,6 +160,7 @@ internal final class PlusAssignSpecialValueTests { /// Tests plus assignment with NaN float value. @Test("Plus assignment with NaN float value generates correct syntax") + @available(*, deprecated, message: "Exercises deprecated PlusAssign API") internal func testPlusAssignWithNaNFloatValue() { let plusAssign = PlusAssign("value", Double.nan) diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpBasicTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpBasicTests.swift index b3a6250c..bb01613f 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpBasicTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpBasicTests.swift @@ -1,13 +1,13 @@ // // ReferenceExpBasicTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpComplexTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpComplexTests.swift index 91a4e6fd..78b76868 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpComplexTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpComplexTests.swift @@ -1,13 +1,13 @@ // // ReferenceExpComplexTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpFunctionTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpFunctionTests.swift index a390e521..647474d5 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpFunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpFunctionTests.swift @@ -1,13 +1,13 @@ // // ReferenceExpFunctionTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpLiteralTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpLiteralTests.swift index f6457a29..a184a35f 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpLiteralTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpLiteralTests.swift @@ -1,13 +1,13 @@ // // ReferenceExpLiteralTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpPropertyTests.swift b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpPropertyTests.swift index c880cbc4..0786e168 100644 --- a/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpPropertyTests.swift +++ b/Tests/SyntaxKitTests/Unit/Expressions/ReferenceExp/ReferenceExpPropertyTests.swift @@ -1,13 +1,13 @@ // // ReferenceExpPropertyTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift b/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift index 572dd728..dba1f784 100644 --- a/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift +++ b/Tests/SyntaxKitTests/Unit/Functions/FunctionTests.swift @@ -1,3 +1,32 @@ +// +// FunctionTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing @@ -58,6 +87,51 @@ internal struct FunctionTests { #expect(normalizedGenerated == normalizedExpected) } + @Test internal func testFunctionWithAccessModifier() throws { + let function = Function("run") { + Call("print") { + ParameterExp(unlabeled: Literal.string("hello")) + } + } + .access(.internal) + + let expected = """ + internal func run() { + print("hello") + } + """ + + let normalizedGenerated = function.syntax.description.normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + + @Test internal func testFunctionThrowingAlias() throws { + // .throwing() is an alias for .throws() that avoids keyword escaping at call sites + let function = Function("load") {} + .throwing() + + let generated = function.syntax.description + #expect(generated.contains("throws")) + #expect(generated.contains("func load")) + } + + @Test internal func testAsyncThrowingFunctionWithAccess() throws { + let function = Function("run") {} + .access(.internal) + .async() + .throwing() + + let expected = """ + internal func run() async throws { + } + """ + + let normalizedGenerated = function.syntax.description.normalize() + let normalizedExpected = expected.normalize() + #expect(normalizedGenerated == normalizedExpected) + } + @Test internal func testMutatingFunction() throws { let function = Function( "updateValue", diff --git a/Tests/SyntaxKitTests/Unit/Integration/FrameworkCompatibilityTests.swift b/Tests/SyntaxKitTests/Unit/Integration/FrameworkCompatibilityTests.swift index b82a0faf..6b4a9715 100644 --- a/Tests/SyntaxKitTests/Unit/Integration/FrameworkCompatibilityTests.swift +++ b/Tests/SyntaxKitTests/Unit/Integration/FrameworkCompatibilityTests.swift @@ -1,3 +1,32 @@ +// +// FrameworkCompatibilityTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTests.swift b/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTests.swift index d687e905..2d36967c 100644 --- a/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTests.swift @@ -1,13 +1,13 @@ // // OptionsMacroIntegrationTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTestsAPI.swift b/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTestsAPI.swift index 88c26d15..0d10a3c2 100644 --- a/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTestsAPI.swift +++ b/Tests/SyntaxKitTests/Unit/Integration/OptionsMacroIntegrationTestsAPI.swift @@ -1,3 +1,32 @@ +// +// OptionsMacroIntegrationTestsAPI.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Migration/AssertionMigrationTests.swift b/Tests/SyntaxKitTests/Unit/Migration/AssertionMigrationTests.swift index 7e1d9fc0..7bfbee15 100644 --- a/Tests/SyntaxKitTests/Unit/Migration/AssertionMigrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/Migration/AssertionMigrationTests.swift @@ -1,3 +1,32 @@ +// +// AssertionMigrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Migration/CodeStyleMigrationTests.swift b/Tests/SyntaxKitTests/Unit/Migration/CodeStyleMigrationTests.swift index f10db711..a64f8672 100644 --- a/Tests/SyntaxKitTests/Unit/Migration/CodeStyleMigrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/Migration/CodeStyleMigrationTests.swift @@ -1,3 +1,32 @@ +// +// CodeStyleMigrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/Migration/MigrationTests.swift b/Tests/SyntaxKitTests/Unit/Migration/MigrationTests.swift index 490c12c8..26b08dd7 100644 --- a/Tests/SyntaxKitTests/Unit/Migration/MigrationTests.swift +++ b/Tests/SyntaxKitTests/Unit/Migration/MigrationTests.swift @@ -1,3 +1,32 @@ +// +// MigrationTests.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation import Testing diff --git a/Tests/SyntaxKitTests/Unit/SwiftUIFeatureTests.swift b/Tests/SyntaxKitTests/Unit/SwiftUIFeatureTests.swift index 42de7ede..030a8def 100644 --- a/Tests/SyntaxKitTests/Unit/SwiftUIFeatureTests.swift +++ b/Tests/SyntaxKitTests/Unit/SwiftUIFeatureTests.swift @@ -1,13 +1,13 @@ // -// SwiftUIExampleTests.swift -// SyntaxKitTests +// SwiftUIFeatureTests.swift +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 @@ -101,13 +101,13 @@ import Testing if let weakRefExp = weakRef as? ReferenceExp { #expect(weakRefExp.captureReferenceType == .weak) } else { - #expect(false, "Expected ReferenceExp type") + Issue.record("Expected ReferenceExp type") } if let unownedRefExp = unownedRef as? ReferenceExp { #expect(unownedRefExp.captureReferenceType == .unowned) } else { - #expect(false, "Expected ReferenceExp type") + Issue.record("Expected ReferenceExp type") } } diff --git a/Tests/SyntaxKitTests/Unit/Utilities/NormalizeOptions.swift b/Tests/SyntaxKitTests/Unit/Utilities/NormalizeOptions.swift index e4e99688..bf679ec3 100644 --- a/Tests/SyntaxKitTests/Unit/Utilities/NormalizeOptions.swift +++ b/Tests/SyntaxKitTests/Unit/Utilities/NormalizeOptions.swift @@ -1,3 +1,32 @@ +// +// NormalizeOptions.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation /// Options for string normalization @@ -20,8 +49,10 @@ public struct NormalizeOptions: OptionSet, Sendable { /// Options for structural comparison (ignores all formatting) public static let structural: NormalizeOptions = [] + /// The raw value backing this option set. public let rawValue: Int + /// Creates a new instance. public init(rawValue: Int) { self.rawValue = rawValue } diff --git a/Tests/SyntaxKitTests/Unit/Utilities/String+NormalizeExtensions.swift b/Tests/SyntaxKitTests/Unit/Utilities/String+NormalizeExtensions.swift index 075b538b..c9c88bc2 100644 --- a/Tests/SyntaxKitTests/Unit/Utilities/String+NormalizeExtensions.swift +++ b/Tests/SyntaxKitTests/Unit/Utilities/String+NormalizeExtensions.swift @@ -1,3 +1,32 @@ +// +// String+NormalizeExtensions.swift +// SyntaxKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + import Foundation extension String { diff --git a/Tests/SyntaxKitTests/Unit/Variables/VariableCoverageTests.swift b/Tests/SyntaxKitTests/Unit/Variables/VariableCoverageTests.swift index 0bda6b3a..da72bde2 100644 --- a/Tests/SyntaxKitTests/Unit/Variables/VariableCoverageTests.swift +++ b/Tests/SyntaxKitTests/Unit/Variables/VariableCoverageTests.swift @@ -1,13 +1,13 @@ // // VariableCoverageTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/Tests/SyntaxKitTests/Unit/Variables/VariableStaticTests.swift b/Tests/SyntaxKitTests/Unit/Variables/VariableStaticTests.swift index 5199d16a..93f169d7 100644 --- a/Tests/SyntaxKitTests/Unit/Variables/VariableStaticTests.swift +++ b/Tests/SyntaxKitTests/Unit/Variables/VariableStaticTests.swift @@ -1,13 +1,13 @@ // // VariableStaticTests.swift -// SyntaxKitTests +// SyntaxKit // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// 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 +// 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 @@ -17,7 +17,7 @@ // 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, +// 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 diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..6df20abb --- /dev/null +++ b/mise.toml @@ -0,0 +1,7 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4"