diff --git a/Cargo.lock b/Cargo.lock index 4fab55a6444..7f972fbb4b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2850,6 +2850,10 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "swift-syntax-rs" +version = "0.1.0" + [[package]] name = "syn" version = "2.0.106" diff --git a/Cargo.toml b/Cargo.toml index 62eb2e7e920..0d9a1c2a84f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "ruby/extractor", "unified/extractor", "unified/extractor/tree-sitter-swift", + "unified/swift-syntax-rs", "rust/extractor", "rust/extractor/macros", "rust/ast-generator", diff --git a/MODULE.bazel b/MODULE.bazel index e6f1ddb893d..aa1f6555e0c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -24,13 +24,15 @@ bazel_dep(name = "rules_python", version = "1.9.0") bazel_dep(name = "rules_shell", version = "0.7.1") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "abseil-cpp", version = "20260107.1", repo_name = "absl") -bazel_dep(name = "nlohmann_json", version = "3.11.3", repo_name = "json") +bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1", repo_name = "json") bazel_dep(name = "fmt", version = "12.1.0-codeql.1") bazel_dep(name = "rules_kotlin", version = "2.2.2-codeql.1") bazel_dep(name = "gazelle", version = "0.50.0") bazel_dep(name = "rules_dotnet", version = "0.21.5-codeql.1") 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") bazel_dep(name = "zstd", version = "1.5.7.bcr.1") bazel_dep(name = "buildifier_prebuilt", version = "6.4.0", dev_dependency = True) @@ -219,6 +221,37 @@ 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`. +swift = use_extension("@rules_swift//swift:extensions.bzl", "swift") +swift.toolchain( + name = "swift_toolchain", + swift_version_file = "//unified/swift-syntax-rs:.swift-version", +) +use_repo( + swift, + "swift_toolchain", + "swift_toolchain_ubuntu24.04", +) + +register_toolchains( + "@swift_toolchain//:swift_toolchain_exec_ubuntu24.04", +) + node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") node.toolchain( name = "nodejs", diff --git a/unified/swift-syntax-rs/.gitignore b/unified/swift-syntax-rs/.gitignore new file mode 100644 index 00000000000..0b649686540 --- /dev/null +++ b/unified/swift-syntax-rs/.gitignore @@ -0,0 +1,2 @@ +/target +/swift/.build diff --git a/unified/swift-syntax-rs/.swift-version b/unified/swift-syntax-rs/.swift-version new file mode 100644 index 00000000000..42cc526d6ca --- /dev/null +++ b/unified/swift-syntax-rs/.swift-version @@ -0,0 +1 @@ +6.2.4 diff --git a/unified/swift-syntax-rs/BUILD.bazel b/unified/swift-syntax-rs/BUILD.bazel new file mode 100644 index 00000000000..10d11a1bec1 --- /dev/null +++ b/unified/swift-syntax-rs/BUILD.bazel @@ -0,0 +1,55 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load("@rules_swift//swift:swift.bzl", "swift_library") + +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`. +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. +swift_library( + name = "swift_syntax_ffi", + srcs = ["swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift"], + module_name = "SwiftSyntaxFFI", + 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. +rust_library( + name = "swift_syntax_rs", + srcs = ["src/lib.rs"], + edition = "2024", + deps = [":swift_syntax_ffi"], +) + +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"], + edition = "2024", + deps = [":swift_syntax_rs"], +) + +rust_test( + name = "swift_syntax_rs_test", + size = "small", + crate = ":swift_syntax_rs", + data = ["@swift_toolchain_ubuntu24.04//:files"], + edition = "2024", +) diff --git a/unified/swift-syntax-rs/Cargo.toml b/unified/swift-syntax-rs/Cargo.toml new file mode 100644 index 00000000000..6957fb0e50e --- /dev/null +++ b/unified/swift-syntax-rs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "swift-syntax-rs" +description = "Rust wrapper around the swift-syntax package for parsing Swift source" +version = "0.1.0" +authors = ["GitHub"] +edition = "2024" + +[lib] +name = "swift_syntax_rs" +path = "src/lib.rs" + +[[bin]] +name = "swift-syntax-parse" +path = "src/main.rs" diff --git a/unified/swift-syntax-rs/README.md b/unified/swift-syntax-rs/README.md new file mode 100644 index 00000000000..08796b3a6a7 --- /dev/null +++ b/unified/swift-syntax-rs/README.md @@ -0,0 +1,180 @@ +# swift-syntax-rs + +A Rust wrapper around the [swift-syntax](https://github.com/swiftlang/swift-syntax) +package, allowing Swift source code to be parsed from Rust. + +Parsing is delegated to a small Swift shim (in [`swift/`](swift/)) that links +against `SwiftSyntax`/`SwiftParser` and exposes a tiny C ABI. The Rust crate +builds that shim (via `build.rs`) and provides safe bindings on top of it. + +## Output format + +The emitted JSON tree preserves the AST's named structure. Every node has a +`kind` and a `range` with `start`/`end` positions (UTF-8 `offset` plus 1-based +`line`/`column`). Beyond that: + +- **Tokens** carry `text`, `tokenKind`, and `leadingTrivia`/`trailingTrivia` + arrays of `{ kind, text }` pieces. +- **Layout nodes** (e.g. `functionDecl`) embed their children directly as + members keyed by the child's name in the parent (`name`, `signature`, + `body`, …), alongside `kind`/`range`. Absent optional children are omitted. +- **Collection nodes** (e.g. `codeBlockItemList`) are elided: a list-valued + field is simply a JSON array of its elements (e.g. `parameters`, `statements`). + This drops the collection node's own `kind`/`range`. + +Only meaningful trivia is kept — the four comment kinds (`lineComment`, +`blockComment`, `docLineComment`, `docBlockComment`) and `unexpectedText` +(source the parser skipped). Whitespace trivia is dropped, since node ranges +already encode positions. + +### Example + +Parsing `let x = 1 // c` produces the following (each `range` object is +abbreviated here as `…`): + +```jsonc +{ + "kind": "sourceFile", + "range": …, + "statements": [ // collection node elided to an array + { + "kind": "codeBlockItem", + "range": …, + "item": { + "kind": "variableDecl", + "range": …, + "attributes": [], // empty collection → empty array + "modifiers": [], + "bindingSpecifier": { // a token + "kind": "token", + "text": "let", + "tokenKind": "keyword(SwiftSyntax.Keyword.let)", + "range": …, + "leadingTrivia": [], + "trailingTrivia": [] + }, + "bindings": [ + { + "kind": "patternBinding", + "range": …, + "pattern": { + "kind": "identifierPattern", + "range": …, + "identifier": { "kind": "token", "text": "x", "tokenKind": "identifier(\"x\")", "range": …, "leadingTrivia": [], "trailingTrivia": [] } + }, + "initializer": { + "kind": "initializerClause", + "range": …, + "equal": { "kind": "token", "text": "=", "tokenKind": "equal", "range": …, "leadingTrivia": [], "trailingTrivia": [] }, + "value": { + "kind": "integerLiteralExpr", + "range": …, + "literal": { + "kind": "token", + "text": "1", + "tokenKind": "integerLiteral(\"1\")", + "range": …, + "leadingTrivia": [], + "trailingTrivia": [ { "kind": "lineComment", "text": "// c" } ] + } + } + } + } + ] + } + } + ], + "endOfFileToken": { "kind": "token", "text": "", "tokenKind": "endOfFile", "range": …, "leadingTrivia": [], "trailingTrivia": [] } +} +``` + +Note how `statements`, `bindings`, `attributes`, and `modifiers` are plain +arrays (their collection nodes are elided), layout children such as +`bindingSpecifier` and `initializer` are embedded by name, and the `// c` +comment rides along as `trailingTrivia` on the token it follows. + +## Prerequisites + +The build does not depend on any particular version manager. You need: + +- **Rust** — pinned to `1.88` by the repo-root [`rust-toolchain.toml`](../../rust-toolchain.toml), + which `rustup` picks up automatically. +- **Swift** — pinned to the version in [`.swift-version`](.swift-version) + (currently `6.2.4`), used to build `swift-syntax` `602.0.0`. Install it any way + you like — [swift.org](https://www.swift.org/install/) or + [swiftly](https://www.swift.org/swiftly/) (which reads `.swift-version`), or a + system package. Just make sure `swift` is on your `PATH` (or point `build.rs` + at it with the `SWIFT` environment variable). + +On Debian/Ubuntu the Swift runtime also needs `libncurses6` (and related libs) +available on the system. + +## Building & testing + +With `cargo` and `swift` on `PATH`: + +```sh +cargo build +cargo test +``` + +If your `swift`/`swiftc` are not on `PATH`, point the build at them explicitly: + +```sh +SWIFT=/path/to/swift SWIFTC=/path/to/swiftc cargo build +``` + +The first build compiles `swift-syntax` and can take several minutes. + +## Building with Bazel (CI) + +CI builds this crate hermetically with Bazel. A Swift toolchain is downloaded +from swift.org by the official `rules_swift` standalone toolchain extension +(wired up in the repo-root `MODULE.bazel`), `swift-syntax` is pulled from the +Bazel Central Registry, and the FFI shim is compiled as a `swift_library` that +the Rust targets link against. `build.rs` is not used under Bazel; it only +builds the Swift shim for the local `cargo` workflow. + +```sh +bazel build //unified/swift-syntax-rs:swift-syntax-parse +bazel test //unified/swift-syntax-rs:swift_syntax_rs_test +bazel run //unified/swift-syntax-rs:swift-syntax-parse < some.swift +``` + +Requirements: + +- **`clang`** must be installed on the runner. `rules_swift` requires the Bazel + CC toolchain to use clang; the repo's `.bazelrc` already sets + `--repo_env=CC=clang`, so no extra flags are needed. +- The registered Swift toolchain currently targets **ubuntu24.04 / x86_64** + only (Bazel cannot auto-select between Linux distributions). Add more + platforms in `MODULE.bazel` (`swift.toolchain` + `register_toolchains`) if CI + grows to cover them. + +The Swift compiler version is read from [`.swift-version`](.swift-version) by +both the Bazel toolchain (`swift.toolchain(swift_version_file = …)`) and the +local build, and is kept in sync with the `swift-syntax` release pinned in +`swift/Package.swift`, so local and CI builds behave identically. + +## Usage + +Library: + +```rust +let json = swift_syntax_rs::parse_to_json("let x = 1")?; +println!("{json}"); +``` + +CLI (reads a file argument or stdin, prints the syntax tree as JSON): + +```sh +echo 'let x = 1' | cargo run --bin swift-syntax-parse +``` + +## Layout + +- `swift/` — Swift package exposing the `ssr_parse_json` / `ssr_string_free` C ABI. +- `build.rs` — builds the Swift package and emits link/rpath flags (local `cargo` only). +- `BUILD.bazel` — Bazel targets for the hermetic CI build (swift_library + rust targets). +- `src/lib.rs` — safe Rust bindings (`parse_to_json`). +- `src/main.rs` — demo CLI. diff --git a/unified/swift-syntax-rs/build.rs b/unified/swift-syntax-rs/build.rs new file mode 100644 index 00000000000..0adb140140d --- /dev/null +++ b/unified/swift-syntax-rs/build.rs @@ -0,0 +1,102 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let swift_dir = manifest_dir.join("swift"); + + println!( + "cargo:rerun-if-changed={}", + swift_dir.join("Package.swift").display() + ); + println!( + "cargo:rerun-if-changed={}", + swift_dir + .join("Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift") + .display() + ); + println!("cargo:rerun-if-env-changed=SWIFT"); + println!("cargo:rerun-if-env-changed=SWIFTC"); + + // Build the Swift FFI package as a release dynamic library. + let mut command = Command::new(swift_bin()); + command + .args(["build", "-c", "release"]) + .current_dir(&swift_dir); + apply_bare_repository_workaround(&mut command); + let status = command.status().unwrap_or_else(|e| { + panic!( + "failed to run `{swift} build`: {e}\n\ + Install a Swift toolchain (see https://www.swift.org/install/, e.g. via \ + swiftly) and ensure `swift` is on PATH, or set the `SWIFT` environment \ + variable to the `swift` executable. The pinned version is in `.swift-version`.", + swift = swift_bin(), + ) + }); + assert!(status.success(), "`swift build` failed"); + + // Link against the freshly built dynamic library. + let build_dir = swift_dir.join(".build/release"); + println!("cargo:rustc-link-search=native={}", build_dir.display()); + println!("cargo:rustc-link-lib=dylib=SwiftSyntaxFFI"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", build_dir.display()); + + // The executable also needs to find the Swift runtime libraries at run time. + if let Some(runtime) = swift_runtime_dir() { + println!("cargo:rustc-link-search=native={}", runtime.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", runtime.display()); + } +} + +/// Query the active Swift toolchain for the directory containing its runtime +/// shared libraries (e.g. `libswiftCore.so`). +fn swift_runtime_dir() -> Option { + let output = Command::new(swiftc_bin()) + .arg("-print-target-info") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let info = String::from_utf8_lossy(&output.stdout); + + // Extract the value of `"runtimeResourcePath": "..."` without pulling in a + // JSON dependency. + let key = "\"runtimeResourcePath\""; + let start = info.find(key)?; + let rest = &info[start + key.len()..]; + let colon = rest.find(':')?; + let after = &rest[colon + 1..]; + let open = after.find('"')?; + let value_start = &after[open + 1..]; + let close = value_start.find('"')?; + let resource_path = &value_start[..close]; + + Some(PathBuf::from(resource_path).join("linux")) +} + +/// The `swift` driver to invoke: `$SWIFT` if set, otherwise `swift` from `PATH`. +/// This keeps the build tool-agnostic — any Swift install works; no particular +/// version manager is required. +fn swift_bin() -> String { + env::var("SWIFT").unwrap_or_else(|_| "swift".to_string()) +} + +/// The `swiftc` compiler to invoke: `$SWIFTC` if set, otherwise `swiftc` from +/// `PATH`. +fn swiftc_bin() -> String { + env::var("SWIFTC").unwrap_or_else(|_| "swiftc".to_string()) +} + +/// Some environments (notably GitHub Codespaces) inject +/// `GIT_CONFIG_KEY_0=safe.bareRepository` / `GIT_CONFIG_VALUE_0=explicit`, which +/// breaks the cached bare git repositories `swift build` uses. When exactly that +/// key is present, relax it to `all` for the `swift build` subprocess only +/// (rather than unconditionally, which could clobber an unrelated +/// `GIT_CONFIG_VALUE_0`). +fn apply_bare_repository_workaround(command: &mut Command) { + if env::var("GIT_CONFIG_KEY_0").as_deref() == Ok("safe.bareRepository") { + command.env("GIT_CONFIG_VALUE_0", "all"); + } +} diff --git a/unified/swift-syntax-rs/src/lib.rs b/unified/swift-syntax-rs/src/lib.rs new file mode 100644 index 00000000000..83dc5e2c6fb --- /dev/null +++ b/unified/swift-syntax-rs/src/lib.rs @@ -0,0 +1,125 @@ +//! Rust wrapper around the [swift-syntax](https://github.com/swiftlang/swift-syntax) +//! package, allowing Swift source code to be parsed from Rust. +//! +//! The heavy lifting is done by a small Swift shim (see `swift/`) that links +//! against `SwiftSyntax`/`SwiftParser` and exposes a tiny C ABI. This module +//! provides safe Rust bindings on top of that ABI. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +// C ABI exported by the `SwiftSyntaxFFI` dynamic library. +unsafe extern "C" { + /// Parse a NUL-terminated Swift source string, returning a heap-allocated + /// NUL-terminated JSON string (or null on failure). The caller owns the + /// returned pointer and must release it with `ssr_string_free`. + fn ssr_parse_json(source: *const c_char) -> *mut c_char; + + /// Free a string previously returned by `ssr_parse_json`. + fn ssr_string_free(ptr: *mut c_char); +} + +/// Errors that can occur while parsing Swift source. +#[derive(Debug)] +pub enum ParseError { + /// The provided source contained an interior NUL byte. + NulByte, + /// The Swift side failed to produce a result. + SwiftFailure, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseError::NulByte => write!(f, "source contained an interior NUL byte"), + ParseError::SwiftFailure => write!(f, "swift-syntax failed to parse the source"), + } + } +} + +impl std::error::Error for ParseError {} + +/// Parse the given Swift `source` and return a JSON representation of its +/// syntax tree. +/// +/// # Example +/// ```no_run +/// let json = swift_syntax_rs::parse_to_json("let x = 1").unwrap(); +/// println!("{json}"); +/// ``` +pub fn parse_to_json(source: &str) -> Result { + let c_source = CString::new(source).map_err(|_| ParseError::NulByte)?; + + // SAFETY: `c_source` is a valid NUL-terminated string for the duration of + // the call. The returned pointer, if non-null, is owned by us and freed via + // `ssr_string_free` before returning. + unsafe { + let ptr = ssr_parse_json(c_source.as_ptr()); + if ptr.is_null() { + return Err(ParseError::SwiftFailure); + } + let json = CStr::from_ptr(ptr).to_string_lossy().into_owned(); + ssr_string_free(ptr); + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simple_declaration() { + let json = parse_to_json("let x = 1").expect("parsing should succeed"); + assert!( + json.contains("\"kind\":\"sourceFile\""), + "unexpected tree: {json}" + ); + assert!(json.contains("\"text\":\"x\""), "unexpected tree: {json}"); + // Source ranges are emitted for every node. + assert!(json.contains("\"range\""), "missing ranges: {json}"); + assert!( + json.contains("\"line\"") && json.contains("\"column\"") && json.contains("\"offset\""), + "missing location fields: {json}" + ); + } + + #[test] + fn preserves_named_structure() { + let json = parse_to_json("let x = 1").expect("parsing should succeed"); + // Layout children are embedded directly under their field name. + assert!( + json.contains("\"initializer\""), + "missing initializer field: {json}" + ); + // Collection-valued fields are elided to plain JSON arrays. + assert!( + json.contains("\"statements\":["), + "statements should be an array: {json}" + ); + assert!( + json.contains("\"bindings\":["), + "bindings should be an array: {json}" + ); + } + + #[test] + fn parses_empty_source() { + let json = parse_to_json("").expect("parsing empty source should succeed"); + assert!( + json.contains("\"kind\":\"sourceFile\""), + "unexpected tree: {json}" + ); + } + + #[test] + fn captures_trivia() { + // A leading comment is kept as trivia on the token it precedes. + let json = parse_to_json("// hi\nlet x = 1").expect("parsing should succeed"); + assert!(json.contains("\"leadingTrivia\""), "missing trivia: {json}"); + assert!( + json.contains("lineComment"), + "comment trivia not captured: {json}" + ); + } +} diff --git a/unified/swift-syntax-rs/src/main.rs b/unified/swift-syntax-rs/src/main.rs new file mode 100644 index 00000000000..d79cd2ffef1 --- /dev/null +++ b/unified/swift-syntax-rs/src/main.rs @@ -0,0 +1,42 @@ +//! Small demo CLI: parse Swift source and print the syntax tree as JSON. +//! +//! Usage: +//! swift-syntax-parse [FILE] +//! +//! Reads from FILE if given, otherwise from standard input. + +use std::io::Read; +use std::process::ExitCode; + +fn main() -> ExitCode { + let source = match read_source() { + Ok(s) => s, + Err(e) => { + eprintln!("error reading source: {e}"); + return ExitCode::FAILURE; + } + }; + + match swift_syntax_rs::parse_to_json(&source) { + Ok(json) => { + println!("{json}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +fn read_source() -> std::io::Result { + let mut args = std::env::args().skip(1); + match args.next() { + Some(path) => std::fs::read_to_string(path), + None => { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } + } +} diff --git a/unified/swift-syntax-rs/swift/Package.resolved b/unified/swift-syntax-rs/swift/Package.resolved new file mode 100644 index 00000000000..9cebf34ec54 --- /dev/null +++ b/unified/swift-syntax-rs/swift/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ad84ad340ee01aa6e38b877dd29e38fa9cb40603dfdefa4e6a69e27f2db208d1", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + } + ], + "version" : 3 +} diff --git a/unified/swift-syntax-rs/swift/Package.swift b/unified/swift-syntax-rs/swift/Package.swift new file mode 100644 index 00000000000..1b0111364ce --- /dev/null +++ b/unified/swift-syntax-rs/swift/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "SwiftSyntaxFFI", + products: [ + // Dynamic library so the produced .so records its dependency on the + // Swift runtime libraries, keeping the Rust link step simple. + .library( + name: "SwiftSyntaxFFI", + type: .dynamic, + targets: ["SwiftSyntaxFFI"] + ) + ], + dependencies: [ + .package( + url: "https://github.com/swiftlang/swift-syntax.git", + exact: "602.0.0" + ) + ], + targets: [ + .target( + name: "SwiftSyntaxFFI", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ) + ] +) diff --git a/unified/swift-syntax-rs/swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift b/unified/swift-syntax-rs/swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift new file mode 100644 index 00000000000..177e8ff319d --- /dev/null +++ b/unified/swift-syntax-rs/swift/Sources/SwiftSyntaxFFI/SwiftSyntaxFFI.swift @@ -0,0 +1,145 @@ +import Foundation +import SwiftParser + +// `@_spi(RawSyntax)` exposes the `childName(_:)` helper that maps a child's +// key path to its field name in the parent layout; used to emit named fields. +@_spi(RawSyntax) import SwiftSyntax + +#if canImport(Glibc) + import Glibc +#elseif canImport(Darwin) + import Darwin +#endif + +/// Convert an absolute position into an `{ offset, line, column }` dictionary. +/// +/// `offset` is a UTF-8 byte offset; `line`/`column` are 1-based. +private func location( + _ position: AbsolutePosition, + _ converter: SourceLocationConverter +) -> [String: Any] { + let loc = converter.location(for: position) + return [ + "offset": position.utf8Offset, + "line": loc.line, + "column": loc.column, + ] +} + +/// Trivia kinds worth preserving in the serialized tree. Comments carry +/// developer intent (including doc comments), and `unexpectedText` flags source +/// the parser had to skip. Whitespace and multi-line-string escape markers are +/// dropped: node ranges already encode positions, so they would only bloat the +/// output. +private let keptTriviaKinds: Set = [ + "lineComment", + "blockComment", + "docLineComment", + "docBlockComment", + "unexpectedText", +] + +/// Serialize a trivia collection into an array of `{ kind, text }` pieces, +/// keeping only the kinds in `keptTriviaKinds`. +private func serializeTrivia(_ trivia: Trivia) -> [Any] { + trivia.pieces.compactMap { piece -> [String: Any]? in + // The label of an enum case mirror is the case name (e.g. "spaces", + // "lineComment"), which gives us a stable kind without an exhaustive + // switch over every `TriviaPiece` case. + let kind = Mirror(reflecting: piece).children.first?.label ?? "\(piece)" + guard keptTriviaKinds.contains(kind) else { return nil } + return [ + "kind": kind, + "text": Trivia(pieces: [piece]).description, + ] + } +} + +/// Recursively convert a SwiftSyntax node into a JSON-serializable value. +/// +/// * Tokens and layout nodes (e.g. `functionDecl`) become objects carrying +/// `kind` and source `range`. Layout nodes additionally embed their children +/// directly as members keyed by the child's name in the parent (e.g. `name`, +/// `signature`, `body`); absent optional children are omitted. Field names +/// never collide with `kind`/`range`. +/// * Collection nodes (e.g. `codeBlockItemList`) are *elided*: they become a +/// plain array of their serialized elements, taking the place of the +/// collection node itself. A list-valued layout field (e.g. `parameters`) is +/// therefore simply a JSON array. This drops the collection node's own +/// `kind`/`range`, which are unnamed and largely recoverable from the +/// elements. +private func serialize( + _ node: Syntax, + _ converter: SourceLocationConverter +) -> Any { + if node.kind.isSyntaxCollection { + return node.children(viewMode: .sourceAccurate).map { + serialize($0, converter) + } + } + + // Source range covering the node's content, excluding surrounding trivia. + let range: [String: Any] = [ + "start": location(node.positionAfterSkippingLeadingTrivia, converter), + "end": location(node.endPositionBeforeTrailingTrivia, converter), + ] + + if let token = node.as(TokenSyntax.self) { + return [ + "kind": "token", + "tokenKind": "\(token.tokenKind)", + "text": token.text, + "range": range, + "leadingTrivia": serializeTrivia(token.leadingTrivia), + "trailingTrivia": serializeTrivia(token.trailingTrivia), + ] + } + + var result: [String: Any] = [ + "kind": "\(node.kind)", + "range": range, + ] + var unnamed = 0 + for child in node.children(viewMode: .sourceAccurate) { + // Layout children are named; embed each under its field name in the + // parent (the same mechanism SwiftSyntax uses for its debug dump). A + // child that is a collection serializes to an array (see above). + if let keyPath = child.keyPathInParent, let name = childName(keyPath) { + result[name] = serialize(child, converter) + } else { + // Defensive fallback for any unnamed layout child. + result["child\(unnamed)"] = serialize(child, converter) + unnamed += 1 + } + } + return result +} + +/// Parse the given NUL-terminated Swift source string and return a +/// heap-allocated, NUL-terminated JSON representation of the syntax tree. +/// +/// The returned pointer is owned by the caller and MUST be released with +/// `ssr_string_free`. Returns `nil` on failure. +@_cdecl("ssr_parse_json") +public func ssr_parse_json(_ source: UnsafePointer?) -> UnsafeMutablePointer? { + guard let source = source else { return nil } + let code = String(cString: source) + let tree = Parser.parse(source: code) + let converter = SourceLocationConverter(fileName: "", tree: tree) + let json = serialize(Syntax(tree), converter) + + guard + let data = try? JSONSerialization.data( + withJSONObject: json, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) + else { + return nil + } + return strdup(string) +} + +/// Free a string previously returned by this library. +@_cdecl("ssr_string_free") +public func ssr_string_free(_ ptr: UnsafeMutablePointer?) { + free(ptr) +}