Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a8edbcf
Initial plan
Copilot Jul 13, 2026
29dcff8
Add macOS (xcode) support to swift-syntax-rs build
Copilot Jul 13, 2026
13d2a12
swift-syntax-rs: make BUILD.bazel targets Windows-incompatible; expli…
Copilot Jul 13, 2026
0662060
Set macOS minimum deployment target for Swift builds in .bazelrc
Copilot Jul 13, 2026
4ba85eb
Also set MACOSX_DEPLOYMENT_TARGET in action_env for exec config
Copilot Jul 13, 2026
377ee49
Scope CC=clang to Linux; use --macos_minimum_os=10.14 on macOS
Copilot Jul 13, 2026
aebf307
Set macOS deployment target back to 10.15
Copilot Jul 13, 2026
4596c69
Scope macOS deployment-target fix to Swift compiles via --swiftcopt=-…
Copilot Jul 13, 2026
381a4a1
Register --swiftcopt flag_alias in .bazelrc for macOS Swift builds
Copilot Jul 13, 2026
e452647
Disable swift-autolink-extract on macOS to fix Swift build
Copilot Jul 13, 2026
967e298
Patch rules_swift to skip Linux-only linkopts on macOS
Copilot Jul 13, 2026
0122729
rules_swift patch: point macOS platform_lib_dir at usr/lib/swift/macosx
Copilot Jul 13, 2026
66071dd
Use xcode_swift_toolchain on macOS instead of patched Unix toolchain
Copilot Jul 13, 2026
7c84693
Wire up apple_support xcode_config so @system_sdk selects resolve on …
Copilot Jul 13, 2026
f4c41e4
bazel: give apple_support cc toolchains priority on macOS
Copilot Jul 13, 2026
dca60ea
bazel: use_repo(apple_cc_configure, "local_config_apple_cc_toolchains")
Copilot Jul 13, 2026
86bcb72
bazel: drop swiftcopt -target workaround (obsoleted by apple_cc toolc…
Copilot Jul 13, 2026
de5c1f7
bazel: drop --swiftcopt/--host_swiftcopt flag aliases (no longer used)
Copilot Jul 13, 2026
a29be0b
clean up wordy comments across macOS bring-up changes
Copilot Jul 13, 2026
32dcc0f
bazel: scope Xcode-coupled flags to Swift builds via --config=swift_m…
Copilot Jul 13, 2026
45a42b0
Revert swift.yml change: swift/ builds C++, not Swift code
Copilot Jul 13, 2026
26ec221
bazel: auto-enable swift_macos on macOS so Swift builds work zero-config
Copilot Jul 13, 2026
720f41a
bazel: scope Xcode flags to //unified/swift-syntax-rs via transition
Copilot Jul 14, 2026
a7dcd56
MODULE.bazel: update apple_support/local_config_* comments per review
Copilot Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ build --compilation_mode opt
common --override_module=semmle_code=%workspace%/misc/bazel/semmle_code_stub

build --repo_env=CC=clang --repo_env=CXX=clang++

# macOS Xcode-coupled flags, kept as an opt-in `--config=swift_macos` for
# manual debugging. The equivalent flags are applied automatically — but
# only to `//unified/swift-syntax-rs` targets — via an incoming-edge
# Starlark transition (see `unified/swift-syntax-rs/xcode_transition.bzl`),
# so other macOS builds continue to use Bazel's built-in `local_config_cc`
# and never materialize the `@local_config_xcode` /
# `@local_config_apple_cc_toolchains` repos.
#
# - `--xcode_version_config` selects `apple_support`'s xcode_config, whose
# version strings match the ones `rules_swift`'s `system_sdk` selects are
# keyed on (Bazel's built-in `host_xcodes` does not match).
# - `--extra_toolchains` forces `apple_support`'s CC toolchain ahead of
# Bazel's built-in `local_config_cc` (whose `target_gnu_system_name` is
# literally "local", which breaks `xcode_swift_toolchain`'s triple
# parsing). Root-level `--extra_toolchains` beats module-registered
# toolchains; only matches Apple platforms.
build:swift_macos --xcode_version_config=@local_config_xcode//:host_xcodes
build:swift_macos --extra_toolchains=@local_config_apple_cc_toolchains//:all

# Disable Android SDK auto-detection (we don't use it, and rules_android has Bazel 9 compatibility issues)
build --repo_env=ANDROID_HOME=

Expand Down
47 changes: 31 additions & 16 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
bazel_dep(name = "rules_rust", version = "0.69.0")
bazel_dep(name = "rules_swift", version = "4.0.0-rc4")
bazel_dep(name = "swift-syntax", version = "602.0.0.bcr.2")

# Direct dep on `apple_support` (transitively pulled in by `rules_swift`) so we
# can `use_repo` `local_config_xcode` and `local_config_apple_cc_toolchains`
# below. These repos are referenced by the per-target Xcode-config transition
# in `unified/swift-syntax-rs/xcode_transition.bzl` (primary mechanism) and by
# the opt-in `swift_macos` bazelrc config (manual escape hatch). See
# `.bazelrc`.
bazel_dep(name = "apple_support", version = "2.6.1")
bazel_dep(name = "zstd", version = "1.5.7.bcr.1")

bazel_dep(name = "buildifier_prebuilt", version = "6.4.0", dev_dependency = True)
Expand Down Expand Up @@ -221,22 +229,12 @@ use_repo(
"swift-resource-dir-macos",
)

# Hermetic Swift toolchain (from swift.org) for building the `swift-syntax`
# based Rust wrapper in `unified/swift-syntax-rs`. This is the official
# `rules_swift` standalone-toolchain extension and is independent of the
# patched prebuilt toolchain wired up via `swift_deps` above.
#
# Bazel cannot auto-select between Linux distributions, so the toolchain is
# registered explicitly for the distribution our CI runs on (ubuntu24.04,
# x86_64). Add further platforms here if CI grows to cover them.
#
# We register the `exec` toolchain (normal host compilation, targeting
# linux/x86_64) rather than the `embedded` one, which targets `os:none`
# (Embedded Swift) and would not match a normal host build.
#
# The Swift version is read from `unified/swift-syntax-rs/.swift-version`, which
# is the single source of truth shared with the local `cargo` build (via
# swiftly / any Swift install) and `swift/Package.swift`.
# Hermetic Swift toolchain (swift.org) for building `unified/swift-syntax-rs`.
# Only registered as an `exec` toolchain on Linux; on macOS we rely on
# `rules_swift`'s auto-registered `xcode_swift_toolchain`, which uses the
# host Xcode and links against the OS-provided Swift runtime in `/usr/lib/swift`.
# Version is read from `unified/swift-syntax-rs/.swift-version`, shared with
# the local `cargo` build and `swift/Package.swift`.
swift = use_extension("@rules_swift//swift:extensions.bzl", "swift")
swift.toolchain(
name = "swift_toolchain",
Expand All @@ -252,6 +250,23 @@ register_toolchains(
"@swift_toolchain//:swift_toolchain_exec_ubuntu24.04",
)

# `apple_support`'s xcode_config, selected via `--xcode_version_config` by
# the per-target Xcode-config transition in
# `unified/swift-syntax-rs/xcode_transition.bzl` (and by the opt-in
# `swift_macos` bazelrc config; see `.bazelrc`). Needed because rules_swift's
# `system_sdk` selects use Xcode version strings from `apple_support`'s
# `xcode_locator.m`, which Bazel's built-in `host_xcodes` does not match.
xcode_configure = use_extension("@apple_support//xcode:xcode_configure.bzl", "xcode_configure_extension")
use_repo(xcode_configure, "local_config_xcode")

# `apple_support`'s CC toolchains, forced ahead of Bazel's built-in
# `local_config_cc` via `--extra_toolchains` by the per-target Xcode-config
# transition in `unified/swift-syntax-rs/xcode_transition.bzl` (and by the
# opt-in `swift_macos` bazelrc config; see `.bazelrc`). Must be visible from
# the root module for those references to resolve.
apple_cc_configure = use_extension("@apple_support//crosstool:setup.bzl", "apple_cc_configure_extension")
use_repo(apple_cc_configure, "local_config_apple_cc_toolchains")

node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(
name = "nodejs",
Expand Down
85 changes: 33 additions & 52 deletions unified/extractor/src/languages/swift/adapter.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
//! Converts the swift-syntax JSON syntax tree into a [`yeast::Ast`], the
//! in-memory format the CodeQL desugaring rules operate on.
//! Converts the swift-syntax JSON syntax tree into a [`yeast::Ast`].
//!
//! The JSON tree is produced by the `swift-syntax-rs` crate's Swift FFI shim
//! (`parse_to_json`). This module is pure Rust (only `yeast` + `serde_json`),
//! so the extractor consumes swift-syntax output without pulling in the Swift
//! toolchain (the JSON is produced out-of-process).
//! The JSON is produced out-of-process by `swift-syntax-rs`'s FFI shim, so
//! this module is pure Rust (no Swift toolchain needed).
//!
//! The mapping mirrors tree-sitter's node model, which is what yeast (and the
//! extractor's rewrite rules) expect:
//! The mapping mirrors tree-sitter's node model:
//!
//! * **Layout nodes** (e.g. `functionDecl`) and **varying tokens** (identifiers,
//! literals, operators — the ones whose text is not determined by their kind)
//! become **named** nodes, keyed by their kind name.
//! * **Fixed tokens** (keywords and punctuation, whose text is fully determined
//! by their kind) become **anonymous** nodes, keyed by their text — exactly
//! how tree-sitter models anonymous tokens (e.g. `"func"`, `"->"`).
//! * Collection nodes are already elided to JSON arrays upstream, so a
//! list-valued field maps directly to that field holding several children.
//! * **Layout nodes** (e.g. `functionDecl`) and **varying tokens**
//! (identifiers, literals, operators — text not determined by kind) become
//! **named** nodes, keyed by their kind name.
//! * **Fixed tokens** (keywords, punctuation) become **anonymous** nodes,
//! keyed by their text (like tree-sitter's anonymous tokens).
//! * Collection nodes are already elided to JSON arrays upstream.
//!
//! Note: this preserves swift-syntax's own kind/field names. Aligning those
//! names with the tree-sitter-swift schema (so the rewrite rules in
//! [`super::swift`] fire) is done incrementally in the rules.
//! Kind/field names are preserved from swift-syntax; aligning them with the
//! tree-sitter-swift schema is done incrementally in the rewrite rules.

use std::collections::BTreeMap;

Expand All @@ -29,32 +22,29 @@ use yeast::schema::Schema;
use yeast::{Ast, Id, NodeContent, Point, Range};

/// A comment (or `unexpectedText`) recovered from the syntax tree's trivia.
///
/// These are collected into a side channel rather than embedded in the
/// [`yeast::Ast`], mirroring how the extractor treats tree-sitter `extra`
/// nodes: they carry a location and text but are not attached to a parent.
/// Collected into a side channel rather than embedded in the [`yeast::Ast`],
/// mirroring how the extractor treats tree-sitter `extra` nodes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriviaToken {
/// The trivia kind (e.g. `lineComment`, `blockComment`, `docLineComment`,
/// `docBlockComment`, `unexpectedText`).
/// The trivia kind (e.g. `lineComment`, `docLineComment`, `unexpectedText`).
pub kind: String,
/// The verbatim source text of the piece (e.g. `// comment`).
/// The verbatim source text (e.g. `// comment`).
pub text: String,
/// The source range the piece occupies.
pub range: Range,
}

/// The result of adapting a swift-syntax JSON tree: the [`yeast::Ast`] plus the
/// comment/`unexpectedText` trivia harvested from it (in source order).
/// The result of adapting a swift-syntax JSON tree: the [`yeast::Ast`] plus
/// the comment/`unexpectedText` trivia harvested from it (in source order).
pub struct AdaptedTree {
pub ast: Ast,
pub trivia: Vec<TriviaToken>,
}

/// swift-syntax `TokenKind` cases whose text is *not* determined by the kind
/// (i.e. `TokenKind.defaultText == nil`). These carry varying information and
/// are modelled as named leaf nodes; every other token is a fixed
/// keyword/punctuation token modelled as an anonymous token keyed by its text.
/// (i.e. `TokenKind.defaultText == nil`). Modelled as named leaf nodes; every
/// other token is a fixed keyword/punctuation, modelled as an anonymous token
/// keyed by its text.
const VARYING_TOKEN_KINDS: &[&str] = &[
"identifier",
"integerLiteral",
Expand Down Expand Up @@ -116,7 +106,7 @@ fn classify(node: &Value) -> Result<KindInfo, String> {
.unwrap_or("")
.to_string();

// The case name is the part before any `(payload)` in the debug rendering.
// Case name is the part before any `(payload)` in the debug rendering.
let case_name = token_kind.split('(').next().unwrap_or(token_kind);

if VARYING_TOKEN_KINDS.contains(&case_name) {
Expand All @@ -142,8 +132,7 @@ fn classify(node: &Value) -> Result<KindInfo, String> {
}
}

/// Iterate over a node object's structural (field, value) pairs in a stable
/// order, skipping metadata keys.
/// Iterate over a node's structural (field, value) pairs, skipping metadata.
fn field_entries(node: &Value) -> Vec<(&str, &Value)> {
node.as_object()
.map(|map| {
Expand All @@ -155,8 +144,8 @@ fn field_entries(node: &Value) -> Vec<(&str, &Value)> {
.unwrap_or_default()
}

/// The child node objects held by a field value, which is either a single node
/// object or an array of them (an elided collection).
/// Child nodes held by a field value: either a single node or an elided
/// collection (JSON array).
fn children_of(value: &Value) -> Vec<&Value> {
match value {
Value::Array(items) => items.iter().collect(),
Expand All @@ -165,12 +154,8 @@ fn children_of(value: &Value) -> Vec<&Value> {
}

/// Recursively build `node` (and its descendants) into `ast`, returning its id.
///
/// This is a single traversal: each node's kind and field names are registered
/// in the schema on the fly, immediately before the node is created. Children
/// are built first so a parent's field lists reference existing ids. Any
/// comment/`unexpectedText` trivia carried by a token is harvested into
/// `trivia` during the same pass rather than embedded in the tree.
/// Kinds and field names are registered in the schema on the fly. Comment /
/// `unexpectedText` trivia is harvested into `trivia` in the same pass.
fn build(node: &Value, ast: &mut Ast, trivia: &mut Vec<TriviaToken>) -> Result<Id, String> {
let info = classify(node)?;
collect_trivia(node, trivia);
Expand Down Expand Up @@ -200,9 +185,8 @@ fn build(node: &Value, ast: &mut Ast, trivia: &mut Vec<TriviaToken>) -> Result<I
))
}

/// Harvest a token's `leadingTrivia`/`trailingTrivia` pieces (each already
/// filtered to comments/`unexpectedText` upstream) into `out`. Non-token nodes
/// have no trivia keys, so this is a no-op for them.
/// Harvest a token's `leadingTrivia`/`trailingTrivia` pieces (already filtered
/// upstream) into `out`. No-op on non-token nodes.
fn collect_trivia(node: &Value, out: &mut Vec<TriviaToken>) {
for key in ["leadingTrivia", "trailingTrivia"] {
let Some(Value::Array(pieces)) = node.get(key) else {
Expand Down Expand Up @@ -231,11 +215,9 @@ fn collect_trivia(node: &Value, out: &mut Vec<TriviaToken>) {

/// Parse a node's `range` into a [`yeast::Range`].
///
/// The JSON carries, for `start` and `end`, a 0-based UTF-8 file byte `offset`,
/// a 1-based `line`, and a 1-based UTF-8 byte `column`. yeast (like tree-sitter)
/// uses byte offsets with 0-based rows/columns and an exclusive end, so the
/// line/column are shifted down by one. swift-syntax's end position is already
/// exclusive, so the byte offsets map across directly.
/// JSON offsets are 0-based UTF-8 byte offsets; `line`/`column` are 1-based
/// UTF-8. yeast uses byte offsets with 0-based rows/columns (exclusive end),
/// so line/column are shifted down by one.
fn parse_range(node: &Value) -> Option<Range> {
let range = node.get("range")?;
let point = |key: &str| -> Option<(usize, Point)> {
Expand All @@ -259,8 +241,7 @@ fn parse_range(node: &Value) -> Option<Range> {
}

/// Convert a swift-syntax JSON tree (as produced by [`crate::parse_to_json`])
/// into a [`yeast::Ast`] plus the comment/`unexpectedText` trivia harvested
/// from it. Both are produced in a single traversal.
/// into a [`yeast::Ast`] plus harvested trivia, in a single traversal.
pub fn json_to_ast(json: &str) -> Result<AdaptedTree, String> {
let root: Value = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;

Expand Down
19 changes: 6 additions & 13 deletions unified/extractor/src/languages/swift/swift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,19 +135,12 @@ fn translation_rules() -> Vec<Rule<SwiftContext>> {
// Declarations may be wrapped in local/global wrapper nodes.
rule!((global_declaration _ @inner) => stmt { inner }),
rule!((local_declaration _ @inner) => stmt { inner }),
// ---- swift-syntax front-end (minimal hook-up) ----
// These rules target the swift-syntax AST (camelCase kind names),
// produced by the sibling `adapter` module. They coexist with the
// tree-sitter rules (snake_case names): rules are dispatched by exact
// kind name, and the two name spaces never collide, so these are inert
// on the tree-sitter path. Only the minimal top-level mapping lives here
// to demonstrate the pipeline end-to-end; the full translation is added
// separately. Unmatched swift-syntax nodes fall through to the
// `unsupported_node` fallback at the end.
//
// `sourceFile` holds its top-level statements in an (elided)
// `statements` collection; each element is a `codeBlockItem` wrapping
// the real node.
// ---- swift-syntax front-end ----
// Rules targeting the swift-syntax AST (camelCase kind names, built
// by the sibling `adapter` module). Kind namespaces don't overlap
// with the tree-sitter (snake_case) rules, so these are inert on
// the tree-sitter path. Unmatched nodes fall through to the
// `unsupported_node` fallback below.
rule!(
(sourceFile statements: _* @items)
=>
Expand Down
64 changes: 43 additions & 21 deletions unified/swift-syntax-rs/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,60 +1,82 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
load("@rules_rust//rust:defs.bzl", "rust_library")
load("@rules_swift//swift:swift.bzl", "swift_library")
load(":xcode_transition.bzl", "xcode_transition_rust_binary", "xcode_transition_rust_test")

package(default_visibility = ["//visibility:public"])

# The pinned Swift version, shared with the local `cargo` build and
# `swift/Package.swift`. Referenced by the `swift.toolchain` extension in
# //:MODULE.bazel via `swift_version_file`.
# Pinned Swift version, shared with `cargo` and `swift/Package.swift`.
# Referenced by `swift.toolchain(swift_version_file = ...)` in //:MODULE.bazel.
exports_files([".swift-version"])

# The Swift FFI shim: wraps swift-syntax and exposes a small C ABI
# (`ssr_parse_json` / `ssr_string_free`). Built with the hermetic Swift
# toolchain registered in //:MODULE.bazel; provides a `CcInfo` that the Rust
# targets below link against.
# Targets in this package require a Swift toolchain (Linux or macOS).
# `select()` gives us OR-of-OSes; other platforms get marked incompatible
# so `bazel build/test //...` skips them cleanly.
_SWIFT_SUPPORTED_PLATFORMS = select({
"@platforms//os:linux": [],
"@platforms//os:macos": [],
"//conditions:default": ["@platforms//:incompatible"],
})

# Swift FFI shim: wraps swift-syntax and exposes a small C ABI. The Rust
# targets below link against its `CcInfo`.
#
# Tagged `manual` so it is only built through the `xcode_transition_*`
# wrappers below (which apply the Xcode-config transition on macOS).
# Building it directly on macOS via `bazel build //...` would try to
# analyze `xcode_swift_toolchain` under Bazel's default CC toolchain and
# fail; going via a wrapper avoids that.
swift_library(
name = "swift_syntax_ffi",
srcs = ["swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift"],
module_name = "SwiftSyntaxFFI",
tags = ["manual"],
target_compatible_with = _SWIFT_SUPPORTED_PLATFORMS,
deps = [
"@swift-syntax//:SwiftParser",
"@swift-syntax//:SwiftSyntax",
],
)

# Safe Rust bindings on top of the C ABI. `build.rs` is intentionally *not*
# wired up here (no `cargo_build_script`): under Bazel the Swift side is
# provided by `:swift_syntax_ffi`, whereas `build.rs` only exists to build the
# Swift shim in the local `cargo` workflow.
# Safe Rust bindings on top of the C ABI. Under Bazel the Swift side comes
# from `:swift_syntax_ffi`; `build.rs` is only used by the `cargo` workflow.
rust_library(
name = "swift_syntax_rs",
srcs = glob(
["src/**/*.rs"],
exclude = ["src/main.rs"],
),
edition = "2024",
tags = ["manual"],
target_compatible_with = _SWIFT_SUPPORTED_PLATFORMS,
deps = [
":swift_syntax_ffi",
],
)

rust_binary(
xcode_transition_rust_binary(
name = "swift-syntax-parse",
srcs = ["src/main.rs"],
# The Swift toolchain propagates a runfiles-relative RPATH to its runtime
# `.so`s via `swift_syntax_ffi`'s `CcInfo`, but (unlike `swift_binary`)
# `rust_binary` does not copy those libraries into runfiles. Add the
# toolchain files as `data` so the runtime is found at execution time.
# (rules_swift does not yet support statically linking the runtime.)
data = ["@swift_toolchain_ubuntu24.04//:files"],
# `rust_binary` doesn't copy the Swift runtime into runfiles the way
# `swift_binary` does. On Linux, ship the standalone toolchain's runtime;
# on macOS the OS provides it at `/usr/lib/swift` (rpath'd by
# `xcode_swift_toolchain`).
data = select({
"@platforms//os:macos": [],
"@platforms//os:linux": ["@swift_toolchain_ubuntu24.04//:files"],
}),
edition = "2024",
target_compatible_with = _SWIFT_SUPPORTED_PLATFORMS,
deps = [":swift_syntax_rs"],
)

rust_test(
xcode_transition_rust_test(
name = "swift_syntax_rs_test",
size = "small",
crate = ":swift_syntax_rs",
data = ["@swift_toolchain_ubuntu24.04//:files"],
data = select({
"@platforms//os:macos": [],
"@platforms//os:linux": ["@swift_toolchain_ubuntu24.04//:files"],
}),
edition = "2024",
target_compatible_with = _SWIFT_SUPPORTED_PLATFORMS,
)
Loading