From 8dc6c7ee1532151ae0b3fd3f2eccf0f23e170317 Mon Sep 17 00:00:00 2001 From: Shaan Majid Date: Thu, 22 Jan 2026 11:14:22 -0600 Subject: [PATCH 1/4] feat(swift): add Swift language support Implement Swift language support for prek: - Query swift executable and parse version from `swift --version` - Handle both macOS (Apple Swift) and Linux version formats - Normalize versions without patch component (e.g., "6.1" -> "6.1.0") - Handle pre-release versions (e.g., "6.2-dev") - Build Package.swift repos with `swift build -c release` - Use `swift build --show-bin-path` to resolve target-specific bin directory - Add built binaries to PATH when running hooks - Health check verifies swift executable still exists Include unit tests for version parsing covering: - macOS format with swift-driver prefix - Linux format - Multiline output handling - Versions without patch component - Development/nightly versions - Invalid input handling --- crates/prek/src/languages/mod.rs | 6 + crates/prek/src/languages/swift.rs | 309 +++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 crates/prek/src/languages/swift.rs diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index a83955bb0..44c1b02c8 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -30,6 +30,7 @@ mod python; mod ruby; mod rust; mod script; +mod swift; mod system; pub mod version; @@ -45,6 +46,7 @@ static PYTHON: python::Python = python::Python; static RUBY: ruby::Ruby = ruby::Ruby; static RUST: rust::Rust = rust::Rust; static SCRIPT: script::Script = script::Script; +static SWIFT: swift::Swift = swift::Swift; static SYSTEM: system::System = system::System; static UNIMPLEMENTED: Unimplemented = Unimplemented; @@ -137,6 +139,7 @@ impl Language { | Self::Ruby | Self::Rust | Self::Script + | Self::Swift | Self::System ) } @@ -216,6 +219,7 @@ impl Language { Self::Ruby => RUBY.install(hook, store, reporter).await, Self::Rust => RUST.install(hook, store, reporter).await, Self::Script => SCRIPT.install(hook, store, reporter).await, + Self::Swift => SWIFT.install(hook, store, reporter).await, Self::System => SYSTEM.install(hook, store, reporter).await, _ => UNIMPLEMENTED.install(hook, store, reporter).await, } @@ -235,6 +239,7 @@ impl Language { Self::Ruby => RUBY.check_health(info).await, Self::Rust => RUST.check_health(info).await, Self::Script => SCRIPT.check_health(info).await, + Self::Swift => SWIFT.check_health(info).await, Self::System => SYSTEM.check_health(info).await, _ => UNIMPLEMENTED.check_health(info).await, } @@ -283,6 +288,7 @@ impl Language { Self::Ruby => RUBY.run(hook, filenames, store, reporter).await, Self::Rust => RUST.run(hook, filenames, store, reporter).await, Self::Script => SCRIPT.run(hook, filenames, store, reporter).await, + Self::Swift => SWIFT.run(hook, filenames, store, reporter).await, Self::System => SYSTEM.run(hook, filenames, store, reporter).await, _ => UNIMPLEMENTED.run(hook, filenames, store, reporter).await, } diff --git a/crates/prek/src/languages/swift.rs b/crates/prek/src/languages/swift.rs new file mode 100644 index 000000000..5e1004c9b --- /dev/null +++ b/crates/prek/src/languages/swift.rs @@ -0,0 +1,309 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use prek_consts::env_vars::EnvVars; +use prek_consts::prepend_paths; +use semver::Version; +use tracing::debug; + +use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; +use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::languages::LanguageImpl; +use crate::process::Cmd; +use crate::run::run_by_batch; +use crate::store::Store; + +#[derive(Debug, Copy, Clone)] +pub(crate) struct Swift; + +pub(crate) struct SwiftInfo { + pub(crate) version: Version, + pub(crate) executable: PathBuf, +} + +pub(crate) async fn query_swift_info() -> Result { + // Find swift executable + let executable = which::which("swift").context("Swift not found on PATH")?; + + // macOS: "swift-driver version: X.Y.Z Apple Swift version X.Y.Z ..." + // Linux/Windows: "Swift version X.Y.Z ..." + let stdout = Cmd::new("swift", "get swift version") + .arg("--version") + .check(true) + .output() + .await? + .stdout; + + let output = String::from_utf8_lossy(&stdout); + let version = parse_swift_version(&output).context("Failed to parse Swift version")?; + + Ok(SwiftInfo { + version, + executable, + }) +} + +/// Normalize version string to semver format (e.g., "5.10" -> "5.10.0"). +/// Some Swift toolchains report versions without a patch component. +fn normalize_version(version_str: &str) -> String { + // Strip any pre-release suffix (e.g., "6.0-dev" -> "6.0") + let version_str = version_str.split('-').next().unwrap_or(version_str); + if version_str.matches('.').count() == 1 { + format!("{version_str}.0") + } else { + version_str.to_string() + } +} + +fn parse_swift_version(output: &str) -> Option { + for line in output.lines() { + // Try Apple Swift format (macOS) - may appear mid-line + if let Some(idx) = line.find("Apple Swift version ") { + let rest = &line[idx + "Apple Swift version ".len()..]; + if let Some(version_str) = rest.split_whitespace().next() { + if let Ok(version) = normalize_version(version_str).parse() { + return Some(version); + } + } + } + // Try plain Swift format (Linux) - at start of line + if let Some(rest) = line.strip_prefix("Swift version ") { + let version_str = rest.split_whitespace().next()?; + return normalize_version(version_str).parse().ok(); + } + } + None +} + +fn build_dir(env_path: &Path) -> PathBuf { + env_path.join(".build") +} + +const BIN_PATH_KEY: &str = "swift_bin_path"; + +impl LanguageImpl for Swift { + async fn install( + &self, + hook: Arc, + store: &Store, + reporter: &HookInstallReporter, + ) -> Result { + let progress = reporter.on_install_start(&hook); + + let mut info = InstallInfo::new( + hook.language, + hook.env_key_dependencies().clone(), + &store.hooks_dir(), + )?; + + debug!(%hook, target = %info.env_path.display(), "Installing Swift environment"); + + // Query swift info + let swift_info = query_swift_info() + .await + .context("Failed to query Swift info")?; + + // Build if repo has Package.swift + if let Some(repo_path) = hook.repo_path() { + if repo_path.join("Package.swift").exists() { + debug!(%hook, "Building Swift package"); + let build_path = build_dir(&info.env_path); + Cmd::new("swift", "swift build") + .arg("build") + .arg("-c") + .arg("release") + .arg("--package-path") + .arg(repo_path) + .arg("--build-path") + .arg(&build_path) + .check(true) + .output() + .await + .context("Failed to build Swift package")?; + + // Get the actual bin path (includes target triple, e.g., .build/arm64-apple-macosx/release) + let bin_path_output = Cmd::new("swift", "get bin path") + .arg("build") + .arg("-c") + .arg("release") + .arg("--package-path") + .arg(repo_path) + .arg("--build-path") + .arg(&build_path) + .arg("--show-bin-path") + .check(true) + .output() + .await + .context("Failed to get Swift bin path")?; + let bin_path = String::from_utf8_lossy(&bin_path_output.stdout) + .trim() + .to_string(); + debug!(%hook, %bin_path, "Swift bin path"); + info.with_extra(BIN_PATH_KEY, &bin_path); + } else { + debug!(%hook, "No Package.swift found, skipping build"); + } + } + + info.with_toolchain(swift_info.executable) + .with_language_version(swift_info.version); + + info.persist_env_path(); + + reporter.on_install_complete(progress); + + Ok(InstalledHook::Installed { + hook, + info: Arc::new(info), + }) + } + + async fn check_health(&self, info: &InstallInfo) -> Result<()> { + // Verify swift still exists at the stored path + if !info.toolchain.exists() { + anyhow::bail!( + "Swift executable no longer exists at: {}", + info.toolchain.display() + ); + } + + Ok(()) + } + + async fn run( + &self, + hook: &InstalledHook, + filenames: &[&Path], + _store: &Store, + reporter: &HookRunReporter, + ) -> Result<(i32, Vec)> { + let progress = reporter.on_run_start(hook, filenames.len()); + + // Get bin path from install info if a package was built + let new_path = + if let Some(bin_path) = hook.install_info().and_then(|i| i.get_extra(BIN_PATH_KEY)) { + prepend_paths(&[Path::new(bin_path)]).context("Failed to join PATH")? + } else { + EnvVars::var_os(EnvVars::PATH).unwrap_or_default() + }; + + let entry = hook.entry.resolve(Some(&new_path))?; + + let run = async |batch: &[&Path]| { + let mut output = Cmd::new(&entry[0], "swift hook") + .current_dir(hook.work_dir()) + .args(&entry[1..]) + .env(EnvVars::PATH, &new_path) + .envs(&hook.env) + .args(&hook.args) + .args(batch) + .check(false) + .stdin(Stdio::null()) + .pty_output() + .await?; + + reporter.on_run_progress(progress, batch.len() as u64); + + output.stdout.extend(output.stderr); + let code = output.status.code().unwrap_or(1); + anyhow::Ok((code, output.stdout)) + }; + + let results = run_by_batch(hook, filenames, &entry, run).await?; + + reporter.on_run_complete(progress); + + let mut combined_status = 0; + let mut combined_output = Vec::new(); + + for (code, output) in results { + combined_status |= code; + combined_output.extend(output); + } + + Ok((combined_status, combined_output)) + } +} + +#[cfg(test)] +mod tests { + use super::parse_swift_version; + + #[test] + fn test_parse_macos_format() { + // macOS: "swift-driver version: ... Apple Swift version X.Y.Z ..." + let output = "swift-driver version: 1.115.0 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.1 clang-1700.0.13.1)"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 2); + } + + #[test] + fn test_parse_linux_format() { + // Linux/Windows: "Swift version X.Y.Z ..." + let output = "Swift version 6.1.2 (swift-6.1.2-RELEASE)"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 2); + } + + #[test] + fn test_parse_multiline_output() { + // macOS output includes target on second line + let output = r"swift-driver version: 1.115.0 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.1 clang-1700.0.13.1) +Target: arm64-apple-macosx15.0"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 2); + } + + #[test] + fn test_parse_linux_multiline() { + // Linux output includes target on second line + let output = r"Swift version 6.1.2 (swift-6.1.2-RELEASE) +Target: x86_64-unknown-linux-gnu"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 2); + } + + #[test] + fn test_parse_invalid_output() { + assert!(parse_swift_version("").is_none()); + assert!(parse_swift_version("not a version string").is_none()); + assert!(parse_swift_version("version 6.1.2").is_none()); // Missing "Swift" + } + + #[test] + fn test_parse_version_without_patch() { + // Some toolchains report versions without a patch number + let output = "swift-driver version: 1.115.0 Apple Swift version 6.1 (swiftlang-6.1.0.0.1 clang-1700.0.13.1)"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 0); // Normalized to .0 + + // Linux format without patch + let output = "Swift version 6.1 (swift-6.1-RELEASE)"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 1); + assert_eq!(version.patch, 0); + } + + #[test] + fn test_parse_dev_version() { + // Development/nightly versions have -dev suffix + let output = "Swift version 6.2-dev (LLVM abcdef, Swift 123456)"; + let version = parse_swift_version(output).unwrap(); + assert_eq!(version.major, 6); + assert_eq!(version.minor, 2); + assert_eq!(version.patch, 0); + } +} From a896b8b390aee50b06fcf469fec2fdbc696dede7 Mon Sep 17 00:00:00 2001 From: Shaan Majid Date: Thu, 22 Jan 2026 11:14:29 -0600 Subject: [PATCH 2/4] test(swift): add Swift language tests --- crates/prek/tests/languages/main.rs | 1 + crates/prek/tests/languages/swift.rs | 218 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 crates/prek/tests/languages/swift.rs diff --git a/crates/prek/tests/languages/main.rs b/crates/prek/tests/languages/main.rs index 5f55bba2a..7a91b38b1 100644 --- a/crates/prek/tests/languages/main.rs +++ b/crates/prek/tests/languages/main.rs @@ -15,5 +15,6 @@ mod python; mod ruby; mod rust; mod script; +mod swift; mod unimplemented; mod unsupported; diff --git a/crates/prek/tests/languages/swift.rs b/crates/prek/tests/languages/swift.rs new file mode 100644 index 000000000..82da15961 --- /dev/null +++ b/crates/prek/tests/languages/swift.rs @@ -0,0 +1,218 @@ +use std::process::Command; + +use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; +use prek_consts::MANIFEST_FILE; +use prek_consts::env_vars::EnvVars; + +use crate::common::{TestContext, cmd_snapshot}; + +/// Test that a local Swift hook with a system command works. +#[test] +fn local_hook_system_command() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: echo-swift + name: echo-swift + language: swift + entry: echo "Swift hook ran" + always_run: true + verbose: true + pass_filenames: false + "#}); + + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + echo-swift...............................................................Passed + - hook id: echo-swift + - duration: [TIME] + + Swift hook ran + + ----- stderr ----- + "); +} + +/// Test that `language_version` is rejected for Swift. +#[test] +fn language_version_rejected() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: local + name: local + language: swift + entry: swift --version + language_version: '6.0' + always_run: true + pass_filenames: false + "}); + + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to init hooks + caused by: Invalid hook `local` + caused by: Hook specified `language_version: 6.0` but the language `swift` does not support toolchain installation for now + "); +} + +/// Test that health check works after install. +#[test] +fn health_check() { + if !EnvVars::is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: swift-echo + name: swift-echo + language: swift + entry: echo "Hello" + always_run: true + verbose: true + pass_filenames: false + "#}); + + context.git_add("."); + + // First run - installs + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + swift-echo...............................................................Passed + - hook id: swift-echo + - duration: [TIME] + + Hello + + ----- stderr ----- + "); + + // Second run - health check + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + swift-echo...............................................................Passed + - hook id: swift-echo + - duration: [TIME] + + Hello + + ----- stderr ----- + "); +} + +/// Test that a Swift Package.swift is built and the executable is available. +#[test] +fn local_package_build() -> anyhow::Result<()> { + if !EnvVars::is_set(EnvVars::CI) { + return Ok(()); + } + + let swift_hook = TestContext::new(); + swift_hook.init_project(); + swift_hook.configure_git_author(); + swift_hook.disable_auto_crlf(); + + // Create a minimal Swift package + swift_hook + .work_dir() + .child("Package.swift") + .write_str(indoc::indoc! {r#" + // swift-tools-version:6.0 + import PackageDescription + + let package = Package( + name: "prek-swift-test", + targets: [ + .executableTarget(name: "prek-swift-test", path: "Sources") + ] + ) + "#})?; + swift_hook.work_dir().child("Sources").create_dir_all()?; + swift_hook + .work_dir() + .child("Sources/main.swift") + .write_str(indoc::indoc! {r#" + print("Hello from Swift package!") + "#})?; + swift_hook + .work_dir() + .child(MANIFEST_FILE) + .write_str(indoc::indoc! {r" + - id: swift-package-test + name: swift-package-test + entry: prek-swift-test + language: swift + "})?; + swift_hook.git_add("."); + swift_hook.git_commit("Initial commit"); + Command::new("git") + .args(["tag", "v1.0", "-m", "v1.0"]) + .current_dir(swift_hook.work_dir()) + .output()?; + + let context = TestContext::new(); + context.init_project(); + + let hook_url = swift_hook.work_dir().to_str().unwrap(); + context.write_pre_commit_config(&indoc::formatdoc! {r" + repos: + - repo: {hook_url} + rev: v1.0 + hooks: + - id: swift-package-test + verbose: true + always_run: true + pass_filenames: false + ", hook_url = hook_url}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + swift-package-test.......................................................Passed + - hook id: swift-package-test + - duration: [TIME] + + Hello from Swift package! + + ----- stderr ----- + "); + + Ok(()) +} From 2f7de08749bbfcf4c69c84f72b95dfd742c40d85 Mon Sep 17 00:00:00 2001 From: Shaan Majid Date: Thu, 22 Jan 2026 11:14:36 -0600 Subject: [PATCH 3/4] docs(swift): mark Swift as supported in language documentation --- docs/languages.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/languages.md b/docs/languages.md index 962fca50a..5e47e305e 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -326,9 +326,20 @@ Supported formats: ### swift -**Status in prek:** Not supported yet. +**Status in prek:** ✅ Supported. + +prek detects the system Swift installation and runs hooks using the configured `entry`. If the hook repository contains a `Package.swift`, prek builds it in release mode and adds the resulting binaries to PATH. + +Runtime behavior: + +- Uses the system Swift installation (no automatic toolchain management) +- Builds Swift packages with `swift build -c release` +- Build artifacts are stored in the hook environment's `.build/release/` directory +- The `entry` command runs with built binaries available on PATH + +#### `language_version` -Tracking: [#46](https://github.com/j178/prek/issues/46) +Swift does not support `language_version` today. It uses the system `swift` installation. ### pygrep From f739eca000e489160216f1bc98dbc1e34ef509d9 Mon Sep 17 00:00:00 2001 From: Shaan Majid Date: Thu, 22 Jan 2026 11:14:36 -0600 Subject: [PATCH 4/4] ci: stabilize swift toolchain setup across platforms - pin Swift version in env to keep CI deterministic\n- use SwiftyLab/setup-swift on Windows where swift-actions fails\n- install Swift on macOS and reset TOOLCHAINS back to Xcode\n- force CC/CXX/SDKROOT from Xcode so Ruby native gems compile\n\nThis keeps Swift hooks available while restoring Xcode's clang/SDK,\nwhich avoids msgpack native extension failures on macOS. --- .github/workflows/ci.yml | 26 ++++++++++++++++++++ crates/prek/tests/languages/unimplemented.rs | 10 ++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 672116a17..2106b5860 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ env: RUBY_VERSION: "3.4" LUA_VERSION: "5.4" LUAROCKS_VERSION: "3.12.2" + SWIFT_VERSION: "6.2" # Cargo env vars CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 @@ -320,6 +321,11 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} + - name: "Install Swift" + uses: swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a # v2.4.0 + with: + swift-version: ${{ env.SWIFT_VERSION }} + - name: "Cargo test" run: | cargo llvm-cov nextest \ @@ -409,6 +415,20 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} + - name: "Install Swift" + uses: swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a # v2.4.0 + with: + swift-version: ${{ env.SWIFT_VERSION }} + + # setup-swift sets TOOLCHAINS, which breaks native extensions using Xcode/clang. + - name: "Reset Xcode toolchain" + run: | + echo "TOOLCHAINS=" >> "$GITHUB_ENV" + echo "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer" >> "$GITHUB_ENV" + echo "CC=/usr/bin/clang" >> "$GITHUB_ENV" + echo "CXX=/usr/bin/clang++" >> "$GITHUB_ENV" + echo "SDKROOT=$(xcrun --sdk macosx --show-sdk-path)" >> "$GITHUB_ENV" + - name: "Cargo test" run: | cargo nextest run \ @@ -544,6 +564,12 @@ jobs: # A dummy dependency path to enable caching of go modules. cache-dependency-path: LICENSE + - name: "Install Swift" + # `swift-actions/setup-swift` broken on Windows, so use `SwiftyLab/setup-swift` + uses: SwiftyLab/setup-swift@4bbb093f8c68d1dee1caa8b67c681a3f8fe70a91 # v1.12.0 + with: + swift-version: ${{ env.SWIFT_VERSION }} + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # windows only - name: "Install Lua" diff --git a/crates/prek/tests/languages/unimplemented.rs b/crates/prek/tests/languages/unimplemented.rs index 359d91473..421403ba9 100644 --- a/crates/prek/tests/languages/unimplemented.rs +++ b/crates/prek/tests/languages/unimplemented.rs @@ -8,10 +8,10 @@ fn unimplemented_language() { repos: - repo: local hooks: - - id: swift-hook - name: swift-hook - language: swift - entry: cargo run + - id: haskell-hook + name: haskell-hook + language: haskell + entry: ghc --version "}); context.git_add("."); @@ -20,7 +20,7 @@ fn unimplemented_language() { success: true exit_code: 0 ----- stdout ----- - swift-hook...........................................(unimplemented yet)Skipped + haskell-hook.........................................(unimplemented yet)Skipped ----- stderr ----- warning: Some hooks were skipped because their languages are unimplemented.