Compare commits

..

1 Commits

Author SHA1 Message Date
Taus
676c552fb0 unified: Add swift-syntax-rs
Adds an initial prototype of an interface from Rust to Swift, which
enables us to use the `swift-syntax` package for parsing.

At present, the parsed AST is passed between Swift and Rust as a JSON
string.
2026-07-06 21:19:45 +00:00
16 changed files with 752 additions and 7 deletions

4
Cargo.lock generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -18,7 +18,7 @@
.NET 5, .NET 6, .NET 7, .NET 8, .NET 9, .NET 10","``.sln``, ``.slnx``, ``.csproj``, ``.cs``, ``.cshtml``, ``.xaml``"
GitHub Actions,"Not applicable",Not applicable,"``.github/workflows/*.yml``, ``.github/workflows/*.yaml``, ``**/action.yml``, ``**/action.yaml``"
Go (aka Golang), "Go up to 1.26", "Go 1.11 or more recent", ``.go``
Java,"Java 7 to 27 [6]_","javac (OpenJDK and Oracle JDK),
Java,"Java 7 to 26 [6]_","javac (OpenJDK and Oracle JDK),
Eclipse compiler for Java (ECJ) [7]_",``.java``
Kotlin,"Kotlin 1.8.0 to 2.4.0\ *x*","kotlinc",``.kt``
@@ -36,7 +36,7 @@
.. [3] Objective-C, Objective-C++, C++/CLI, and C++/CX are not supported.
.. [4] Support for the clang-cl compiler is preliminary.
.. [5] Support for the Arm Compiler (armcc) is preliminary.
.. [6] Builds that execute on Java 7 to 27 can be analyzed. The analysis understands standard language features in Java 8 to 27; "preview" and "incubator" features are not supported. Source code using Java language versions older than Java 8 are analyzed as Java 8 code.
.. [6] Builds that execute on Java 7 to 26 can be analyzed. The analysis understands standard language features in Java 8 to 26; "preview" and "incubator" features are not supported. Source code using Java language versions older than Java 8 are analyzed as Java 8 code.
.. [7] ECJ is supported when the build invokes it via the Maven Compiler plugin or the Takari Lifecycle plugin.
.. [8] JSX and Flow code, YAML, JSON, HTML, and XML files may also be analyzed with JavaScript files.
.. [9] The extractor requires Python 3 to run. To analyze Python 2.7 you should install both versions of Python.

View File

@@ -1,4 +0,0 @@
---
category: minorAnalysis
---
* The Java extractor and QL libraries now support Java 27.

2
unified/swift-syntax-rs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/swift/.build

View File

@@ -0,0 +1 @@
6.2.4

View File

@@ -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",
)

View File

@@ -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"

View File

@@ -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.

View File

@@ -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<PathBuf> {
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");
}
}

View File

@@ -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<String, ParseError> {
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}"
);
}
}

View File

@@ -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<String> {
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)
}
}
}

View File

@@ -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
}

View File

@@ -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"),
]
)
]
)

View File

@@ -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<String> = [
"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<CChar>?) -> UnsafeMutablePointer<CChar>? {
guard let source = source else { return nil }
let code = String(cString: source)
let tree = Parser.parse(source: code)
let converter = SourceLocationConverter(fileName: "<input>", 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<CChar>?) {
free(ptr)
}