Skip to content

Commit b5d570f

Browse files
authored
Merge pull request #22459 from github/tausbn/unified-add-swift-node-type-generator
unified: Add Swift node type schema generator
2 parents 18dae28 + 51a5929 commit b5d570f

10 files changed

Lines changed: 335 additions & 7 deletions

File tree

unified/AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ by Apple's swift-syntax rather than by tree-sitter.
1616
- `extractor/src/languages/swift/adapter.rs` converts that JSON into a yeast AST.
1717

1818
- The raw parse tree's shape is described by `extractor/swift_node_types.yml`,
19-
which is maintained by hand.
19+
which is generated from swift-syntax by `swift-syntax-rs/schemagen`. Do not
20+
edit it by hand; regenerate it with `scripts/regenerate-node-types.sh` after
21+
changing the pinned swift-syntax version, then review the diff alongside the
22+
mapping in `extractor/src/languages/swift/swift.rs`.
2023

2124
## AST Mapping
2225
- The target AST shape is described by `extractor/ast_types.yml`.

unified/extractor/src/languages/swift/adapter.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ fn parse_range(node: &Value) -> Option<Range> {
261261
})
262262
}
263263

264-
/// The authoritative swift-syntax input node-types schema.
264+
/// The authoritative swift-syntax input node-types schema, generated from
265+
/// swift-syntax by `swift-syntax-rs/schemagen` (run
266+
/// `unified/scripts/regenerate-node-types.sh` to refresh it).
265267
/// [`json_to_ast`] seeds every parse with the schema built from this,
266268
/// pre-registering every input kind and field so rule matching never references
267269
/// a name absent from a given file's tree.

unified/extractor/swift_node_types.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# GENERATED from swift-syntax by unified/swift-syntax-rs/schemagen.
2+
# Do not edit; run unified/scripts/regenerate-node-types.sh instead.
13
supertypes:
24
decl:
35
- accessorDecl
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/bin/bash
2+
# Regenerate `extractor/swift_node_types.yml`, the schema describing the shape
3+
# of the trees produced by `swift_syntax_rs::parse_to_json`, from swift-syntax
4+
# itself.
5+
#
6+
# Run this after changing the pinned swift-syntax version, and review the diff:
7+
# a new or renamed node kind generally means the mapping in
8+
# `extractor/src/languages/swift/swift.rs` needs attention too.
9+
#
10+
# This needs a local Swift toolchain (see `swift-syntax-rs/.swift-version` for
11+
# the pinned version). The schema it derives from lives in `SyntaxSupport`, a
12+
# target of swift-syntax's separate `CodeGeneration` package: it is not a
13+
# product of swift-syntax, and Bazel's swift-syntax module does not export its
14+
# sources, so there is no way to depend on it directly.
15+
set -euo pipefail
16+
IFS=$'\n\t'
17+
18+
root=$(cd "$(dirname "$0")/.." && pwd)
19+
swift_syntax_rs_dir="$root/swift-syntax-rs"
20+
schemagen_dir="$swift_syntax_rs_dir/schemagen"
21+
output="$root/extractor/swift_node_types.yml"
22+
23+
if ! command -v swift >/dev/null 2>&1; then
24+
echo "error: Swift is required; install the version pinned in $swift_syntax_rs_dir/.swift-version." >&2
25+
exit 1
26+
fi
27+
28+
# Codespaces sets `safe.bareRepository=explicit` through environment-based Git
29+
# configuration, which prevents SwiftPM from using its cached bare dependency
30+
# repositories. Relax only that injected setting, and only for Swift
31+
# subprocesses, as `swift-syntax-rs/build.rs` does for local Cargo builds.
32+
run_swift() {
33+
if [[ ${GIT_CONFIG_KEY_0:-} == "safe.bareRepository" ]]; then
34+
GIT_CONFIG_VALUE_0=all swift "$@"
35+
else
36+
swift "$@"
37+
fi
38+
}
39+
40+
echo "Resolving swift-syntax..." >&2
41+
(
42+
cd "$schemagen_dir"
43+
run_swift package resolve >&2
44+
)
45+
checkout="$schemagen_dir/.build/checkouts/swift-syntax"
46+
syntax_support="$checkout/CodeGeneration/Sources/SyntaxSupport"
47+
if [[ ! -d $syntax_support ]]; then
48+
echo "error: $syntax_support not found after resolving swift-syntax." >&2
49+
exit 1
50+
fi
51+
52+
# Refresh rather than merge, so that sources deleted upstream do not linger.
53+
rm -rf "$schemagen_dir/Sources/SyntaxSupport"
54+
cp -R "$syntax_support" "$schemagen_dir/Sources/SyntaxSupport"
55+
56+
echo "Generating $output..." >&2
57+
# Generate to a temporary file first: redirecting straight into `$output` would
58+
# truncate the existing schema before the build has even run, leaving nothing
59+
# behind if it fails.
60+
tmp=$(mktemp)
61+
trap 'rm -f "$tmp"' EXIT
62+
(
63+
cd "$schemagen_dir"
64+
run_swift run schemagen
65+
) > "$tmp"
66+
if [[ ! -s $tmp ]]; then
67+
echo "error: schemagen produced no output; $output left unchanged." >&2
68+
exit 1
69+
fi
70+
mv "$tmp" "$output"
71+
chmod 644 "$output"
72+
echo "Regenerated $output" >&2

unified/swift-syntax-rs/README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,20 @@ cargo test
150150

151151
The first build compiles `swift-syntax` and can take several minutes.
152152

153+
## Regenerating the extractor node types
154+
155+
After updating the pinned swift-syntax version, regenerate the unified
156+
extractor's input schema:
157+
158+
```sh
159+
../scripts/regenerate-node-types.sh
160+
```
161+
162+
The script uses swift-syntax's authoritative `SyntaxSupport` definitions and
163+
requires the local Swift toolchain pinned by [`.swift-version`](.swift-version).
164+
Review the resulting `extractor/swift_node_types.yml` diff alongside the Swift
165+
mapping rules. See [`schemagen/README.md`](schemagen/README.md) for details.
166+
153167
## Building with Bazel (CI)
154168

155169
CI builds this crate hermetically with Bazel. A Swift toolchain is downloaded
@@ -182,12 +196,15 @@ Requirements:
182196
swift.org. The Bazel C++ toolchain must still provide the macOS SDK, but a
183197
full Xcode installation is not required.
184198

185-
The Swift compiler version is kept in sync across three places: the
199+
The Swift compiler version is kept in sync between the
186200
[`.swift-version`](.swift-version) file (read by the local `cargo`/`swift build`
187-
and by [swiftly](https://www.swift.org/swiftly/)), the literal `swift_version`
188-
pinned on `swift.toolchain(...)` in the root `MODULE.bazel` (the hermetic
189-
swift.org Bazel toolchain), and the `swift-syntax` release in
190-
`swift/Package.swift`.
201+
and by [swiftly](https://www.swift.org/swiftly/)) and the literal
202+
`swift_version` pinned on `swift.toolchain(...)` in the root `MODULE.bazel`
203+
(the hermetic swift.org Bazel toolchain).
204+
205+
The swift-syntax version is independently pinned in the root `MODULE.bazel`,
206+
[`swift/Package.swift`](swift/Package.swift), and
207+
[`schemagen/Package.swift`](schemagen/Package.swift). Update all three together.
191208

192209
(The Bazel toolchain pins a literal rather than reading `.swift-version` via
193210
`swift_version_file`, because the latter makes the module extension read a
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/.build
2+
# Copied from swift-syntax's CodeGeneration package by
3+
# `unified/scripts/regenerate-node-types.sh`; not ours to vendor.
4+
/Sources/SyntaxSupport

unified/swift-syntax-rs/schemagen/Package.resolved

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
// `schemagen` regenerates `unified/extractor/swift_node_types.yml`, the input
5+
// schema describing the shape of the trees produced by
6+
// `swift_syntax_rs::parse_to_json`. Run it through
7+
// `unified/scripts/regenerate-node-types.sh`, which stages the sources this
8+
// package needs; see `README.md` for the details.
9+
//
10+
// The tools version is deliberately older than the FFI package's: it selects
11+
// the Swift 5 language mode, and `SyntaxSupport` (see below) is not clean under
12+
// Swift 6 strict concurrency because its node tables are non-Sendable globals.
13+
let package = Package(
14+
name: "schemagen",
15+
platforms: [
16+
// Matches the FFI package: swift-syntax 603 requires macOS 10.15.
17+
.macOS(.v10_15),
18+
],
19+
dependencies: [
20+
// Keep this independent pin synchronized with the swift-syntax pins in
21+
// `../swift/Package.swift` and the repository's `MODULE.bazel`.
22+
.package(
23+
url: "https://github.com/swiftlang/swift-syntax.git",
24+
exact: "603.0.2"
25+
),
26+
],
27+
targets: [
28+
// `SyntaxSupport` is a target of swift-syntax's separate
29+
// `CodeGeneration` package, not a product of swift-syntax itself, so it
30+
// cannot be depended on directly. The regeneration script copies its
31+
// sources here (the directory is git-ignored) and this target builds
32+
// them as if they were our own.
33+
.target(
34+
name: "SyntaxSupport",
35+
dependencies: [
36+
.product(name: "SwiftSyntax", package: "swift-syntax"),
37+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
38+
]
39+
),
40+
.executableTarget(name: "schemagen", dependencies: ["SyntaxSupport"]),
41+
]
42+
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# schemagen
2+
3+
Generates [`unified/extractor/swift_node_types.yml`][schema], the schema that
4+
describes the shape of the trees produced by `swift_syntax_rs::parse_to_json`.
5+
The extractor seeds every parse with it, so rule matching never refers to a
6+
node kind or field that swift-syntax can produce but the schema does not know.
7+
8+
Run it through the script, which stages the sources described below:
9+
10+
```console
11+
$ unified/scripts/regenerate-node-types.sh
12+
```
13+
14+
Do this after changing the pinned swift-syntax version, and read the resulting
15+
diff: a new or renamed node kind usually means the mapping in
16+
[`swift.rs`][mapping] needs attention too.
17+
18+
This requires the local Swift toolchain pinned by
19+
[`.swift-version`](../.swift-version).
20+
21+
## Why the sources are copied in
22+
23+
The schema is derived from `SyntaxSupport`, the module that describes
24+
swift-syntax's own syntax tree. This is the same description swift-syntax
25+
generates itself from, and is therefore authoritative in a way that observing
26+
parser output never would be. The runtime `SwiftSyntax` module is not a
27+
substitute: its `SyntaxNodeStructure` exposes layout as key paths, without the
28+
field names, optionality, and base-kind relationships this schema records.
29+
30+
`SyntaxSupport` is awkward to depend on, though. It is a target of
31+
`CodeGeneration`, a package inside the swift-syntax repository that is
32+
separate from swift-syntax itself, and it is not one of that package's
33+
products. SwiftPM can only depend on products, and Bazel's swift-syntax module
34+
does not export the `CodeGeneration` sources, so neither build system can
35+
reach it directly.
36+
37+
The regeneration script therefore resolves this package's swift-syntax
38+
dependency and copies its `CodeGeneration/Sources/SyntaxSupport` sources into
39+
`Sources/SyntaxSupport`, where this package builds them as its own. That
40+
directory is git-ignored and refreshed on every run, so it always matches
41+
schemagen's pin rather than drifting as a stale vendored copy would.
42+
43+
Schemagen has its own exact swift-syntax pin in `Package.swift`. Keep it
44+
synchronized with the SwiftPM parser pin in `../swift/Package.swift` and the
45+
Bazel pin in the repository's `MODULE.bazel`. The build systems resolve these
46+
independently, so regeneration does not itself guarantee that all three pins
47+
match.
48+
49+
## What is filtered out
50+
51+
The schema describes the JSON the extractor's adapter receives, not
52+
swift-syntax's tree verbatim, so `main.swift` mirrors what
53+
[`adapter.rs`][adapter] does:
54+
55+
- Abstract base kinds become `supertypes:` entries rather than node kinds.
56+
- Collection nodes are dropped, and a collection-typed child is recorded as
57+
its element kinds, because the adapter elides collections into JSON arrays.
58+
- `unexpectedBeforeX`, `unexpectedBetweenXAndY`, and `unexpectedAfterX`
59+
error-recovery children are dropped; no rule matches them. This filters on
60+
the child name: `unexpectedCodeDecl` is a real node kind and is retained.
61+
- Token-typed children become the synthetic `_token` kind. Only the varying
62+
token kinds whose `TokenSpec` is `.other` and has no fixed text are emitted
63+
as kinds of their own. These are derived from `Token.allCases` and should match
64+
`VARYING_TOKEN_KINDS` in `adapter.rs`. Fixed tokens are anonymous and keyed
65+
by their text, so no rule can name them.
66+
67+
Setting `EMIT_SUPERTYPES=0` omits the `supertypes:` section, which can be useful
68+
when diffing two versions for kind and field changes alone.
69+
70+
[schema]: ../../extractor/swift_node_types.yml
71+
[mapping]: ../../extractor/src/languages/swift/swift.rs
72+
[adapter]: ../../extractor/src/languages/swift/adapter.rs
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Foundation
2+
import SyntaxSupport
3+
4+
// Named-leaf ("varying") token kinds, mirroring the extractor adapter's
5+
// VARYING_TOKEN_KINDS. Fixed tokens are anonymous (keyed by text) and are not
6+
// matched by any rule, so they are not emitted here.
7+
let varyingTokens = Token.allCases.compactMap { token -> String? in
8+
let spec = token.spec
9+
guard spec.text == nil else { return nil }
10+
// The generic `keyword` token has no `TokenSpec.text`, but each concrete
11+
// keyword has a fixed spelling carried by its associated value.
12+
guard case .other = spec.kind else { return nil }
13+
return spec.identifier.text
14+
}
15+
16+
// The yeast type references that a child maps to. A collection wrapper is
17+
// elided by the adapter, so a collection child maps to its element kinds.
18+
func typeRefs(_ child: Child) -> [String] {
19+
switch child.kind {
20+
case .node(let kind):
21+
return [kind.rawValue]
22+
case .nodeChoices(let choices, _):
23+
return choices.flatMap { typeRefs($0) }
24+
case .collection(let kind, _, _, _, _):
25+
if let collection = SYNTAX_NODES.first(where: { $0.kind == kind })?.collectionNode {
26+
let elements = collection.elementChoices.map { $0.rawValue }
27+
return elements.isEmpty ? [kind.rawValue] : elements
28+
}
29+
return [kind.rawValue]
30+
case .token:
31+
return ["_token"]
32+
}
33+
}
34+
35+
func isMultiple(_ child: Child) -> Bool {
36+
if case .collection = child.kind {
37+
return true
38+
}
39+
return false
40+
}
41+
42+
var supertypes: [String: [String]] = [:]
43+
var named: [(String, [Child])] = []
44+
45+
for node in SYNTAX_NODES {
46+
if node.kind.isBase {
47+
continue
48+
}
49+
if node.base == .syntaxCollection {
50+
continue
51+
}
52+
supertypes[node.base.rawValue, default: []].append(node.kind.rawValue)
53+
named.append((node.kind.rawValue, node.layoutNode?.children ?? []))
54+
}
55+
56+
var output = ""
57+
output += "# GENERATED from swift-syntax by unified/swift-syntax-rs/schemagen.\n"
58+
output += "# Do not edit; run unified/scripts/regenerate-node-types.sh instead.\n"
59+
let emitSupertypes = ProcessInfo.processInfo.environment["EMIT_SUPERTYPES"] != "0"
60+
if emitSupertypes {
61+
output += "supertypes:\n"
62+
for base in supertypes.keys.sorted() {
63+
output += " \(base):\n"
64+
for member in supertypes[base]!.sorted() {
65+
output += " - \(member)\n"
66+
}
67+
}
68+
}
69+
output += "named:\n"
70+
for (kind, children) in named.sorted(by: { $0.0 < $1.0 }) {
71+
output += " \(kind):\n"
72+
for child in children {
73+
// swift-syntax error-recovery slots (`unexpectedBeforeX`,
74+
// `unexpectedBetweenXAndY`, and `unexpectedAfterX`) are never matched
75+
// by rules.
76+
if child.name.hasPrefix("unexpected") {
77+
continue
78+
}
79+
var key = child.name
80+
if isMultiple(child) {
81+
key += "*"
82+
} else if child.isOptional {
83+
key += "?"
84+
}
85+
let refs = typeRefs(child)
86+
let value = refs.count == 1 ? refs[0] : "[" + refs.joined(separator: ", ") + "]"
87+
output += " \(key): \(value)\n"
88+
}
89+
}
90+
91+
// Synthetic leaf for token-typed fields, plus the named ("varying") token
92+
// kinds that are not already emitted as layout nodes (`stringSegment`, for
93+
// example, is both a node and a token kind and must only be emitted once).
94+
let namedKinds = Set(named.map { $0.0 })
95+
output += " _token:\n"
96+
for token in varyingTokens.sorted() where !namedKinds.contains(token) {
97+
output += " \(token):\n"
98+
}
99+
100+
print(output, terminator: "")

0 commit comments

Comments
 (0)