Skip to content

Commit 9b38385

Browse files
committed
winmd-inspect: read a rendered body's Swift surface off its syntax tree
The closure emit reads two facts off each rendered body's source text: the top-level type names it declares, and — when it nests a value type — where the container's primary declaration opens and closes. Reading these with hand-written character scanners means transcribing Swift's lexical grammar by hand — nested block comments, multiline string literals, brace balancing — one subtlety, and one bug, at a time. Grow a dependency on swift-syntax and read them off the tree instead. `Surface` parses a body with `SwiftParser` and answers each query from the result: `declarations` names the top-level `struct`/`class`/`enum`/`protocol`/`actor` and `typealias`; `partition` splits a body into the file-scope content before its primary declaration, the declaration itself, and the content after; and `inject` splices a nested block before the primary declaration's closing brace. A comment, a string literal, and a nested member are excluded by the grammar rather than by tracked lexer state, so the whole class of scanner bug is gone. `partition` and `inject` locate the primary through the tree, not a brace count: the primary is the top-level declaration named `name` that both names itself (`NamedDeclSyntax`) and bears members (`DeclGroupSyntax`), so a `struct` or `enum` value type and a `protocol` the nesting permits as a child are all found, while an unnamed `extension` footer is not mistaken for it. The declaration starts at its leading syntax token, so the whole attribute list — however many lines an `@available(…)` spans — and a preceding `/** … */` doc comment stay with the type rather than stranding at file scope; it ends at the closing brace's source column, so file-scope code sharing the brace's line (`struct Foo {}; extension Foo {}`) splits into the footer, not the declaration a nesting caller would indent into the enclosing type. A trailing comment folds into the declaration only when it trails the primary to the end of the body; a comment documenting a *following* declaration (`struct Foo {}`, `/// Bar`, `struct Bar {}`) stays in the footer with the type it documents. Which types a signature *references* is not read here. The metadata resolution already records every reference — its owner, the local declaration it resolves to or nil for an external, and its category — so the closure keys renderedness, frontiers, and collisions off that graph rather than re-deriving them from the rendered text. The template spells names; it does not choose the surface. Reading off the tree also handles the shapes a hand scanner stumbles on. `declarations` descends a top-level `#if os(Windows) … #endif`, since the render is platform-agnostic and a template may guard its declaration behind a conditional — a brace count would miss the enclosed type and the emittedness check would drop it. And a malformed body (`struct Foo {` with no close) yields a *missing* `rightBrace` the parser synthesizes at end-of-input; `primary` rejects a non-present brace so the splice and slice take their documented whole-body fallback rather than index an out-of-range line. And a CRLF `-I` override splits on the `\n` *scalar*, not the `Character`: `\r\n` is one grapheme a `Character` split neither breaks on nor recognizes, so a plain split would leave the whole body one unpartitionable line; splitting on the scalar (and counting scalars in `locate`) separates the lines while each keeps its `\r`, so `joined` restores the exact bytes and the delimiter checks trim the trailing `\r` where they read a `*/` or a blank line. This is the foundation the nesting, collision, and frontier slices build on: they call `Surface` rather than each carrying its own scanner, so the render never hand-parses Swift. swift-syntax is pinned to `main` for the dev-snapshot toolchain.
1 parent 7dce41c commit 9b38385

3 files changed

Lines changed: 443 additions & 0 deletions

File tree

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ let _ =
2323
from: "1.5.0"),
2424
.package(url: "https://github.com/hummingbird-project/swift-mustache",
2525
from: "2.0.0"),
26+
.package(url: "https://github.com/swiftlang/swift-syntax",
27+
branch: "main"),
2628
],
2729
targets: [
2830
.target(name: "CPE", dependencies: []),
@@ -102,6 +104,10 @@ let _ =
102104
package: "swift-argument-parser"),
103105
.product(name: "Mustache",
104106
package: "swift-mustache"),
107+
.product(name: "SwiftSyntax",
108+
package: "swift-syntax"),
109+
.product(name: "SwiftParser",
110+
package: "swift-syntax"),
105111
],
106112
resources: [
107113
.copy("Resources"),
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
// Copyright © 2026 Saleem Abdulrasool <compnerd@compnerd.org>. All rights reserved.
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
internal import SwiftSyntax
5+
internal import SwiftParser
6+
7+
/// The lexical surface a rendered body exposes to `nest`: the top-level type
8+
/// names it declares, and the primary declaration's boundaries so a container's
9+
/// nested child splices in while its file-scope header and footer bubble out.
10+
/// Both read off the Swift syntax tree `SwiftParser` produces rather than a
11+
/// character scan, so a comment, a string literal, and a nested declaration are
12+
/// excluded by the grammar rather than by hand-tracked lexer state. Which types
13+
/// a signature *references* is not read here — the metadata resolution records
14+
/// that directly, so the closure never re-derives it from the rendered text.
15+
internal enum Surface {
16+
/// The top-level type names `body` declares as code — the `name` of every
17+
/// `struct`/`class`/`enum`/`protocol`/`actor` and the `name` of every
18+
/// `typealias` at the root of the source file. A top-level
19+
/// conditional-compilation block (`#if os(Windows) … #endif`) is descended
20+
/// into — the render is platform-agnostic, so a declaration a template guards
21+
/// behind a `#if` still counts — but a declaration nested inside another
22+
/// type's member block is a member, not a top-level type.
23+
static func declarations(in body: String) -> Set<String> {
24+
var names = Set<String>()
25+
collect(Parser.parse(source: body).statements, into: &names)
26+
return names
27+
}
28+
29+
private static func collect(_ statements: CodeBlockItemListSyntax,
30+
into names: inout Set<String>) {
31+
for statement in statements {
32+
guard case let .decl(declaration) = statement.item else { continue }
33+
if let conditional = declaration.as(IfConfigDeclSyntax.self) {
34+
for clause in conditional.clauses {
35+
if case let .statements(inner)? = clause.elements {
36+
collect(inner, into: &names)
37+
}
38+
}
39+
} else if let name = named(declaration) {
40+
names.insert(name)
41+
}
42+
}
43+
}
44+
45+
/// The declared name of a top-level *type* declaration — a `struct`, `class`,
46+
/// `enum`, `protocol`, `actor`, or `typealias` — or `nil` for any other
47+
/// declaration (a `func`/`var`/`import` is not a type the render nests).
48+
private static func named(_ declaration: DeclSyntax) -> String? {
49+
if let s = declaration.as(StructDeclSyntax.self) { return s.name.text }
50+
if let c = declaration.as(ClassDeclSyntax.self) { return c.name.text }
51+
if let e = declaration.as(EnumDeclSyntax.self) { return e.name.text }
52+
if let p = declaration.as(ProtocolDeclSyntax.self) { return p.name.text }
53+
if let a = declaration.as(ActorDeclSyntax.self) { return a.name.text }
54+
if let t = declaration.as(TypeAliasDeclSyntax.self) { return t.name.text }
55+
return nil
56+
}
57+
58+
/// The signature position a spelled type occupies — a method or `Invoke`
59+
/// parameter or return, a stored field, or an inheritance base — the category
60+
/// the metadata records each spelled reference under.
61+
enum Category: Hashable {
62+
case parameter
63+
case returned
64+
case base
65+
case field
66+
}
67+
68+
/// Splices the nested-declaration `block` into `body` just before the closing
69+
/// brace of `body`'s primary declaration — the top-level `struct` or `enum`
70+
/// named `name` — so a rendered container carries its nested types inside its
71+
/// own body. The closer is located off the syntax tree rather than a running
72+
/// brace count, so a `{`/`}` in a comment or a string literal is excluded by
73+
/// the grammar, a leading helper (`func`/second type) before the container is
74+
/// skipped, and a brace-delimited footer (`extension`) after it is left
75+
/// untouched.
76+
///
77+
/// When the closing brace shares its line with the declaration body
78+
/// (`struct Outer {}`) the line splits so the child nests between the body
79+
/// and the brace, keeping the brace at the declaration's own indentation;
80+
/// when the brace is on its own line the child inserts as a whole line before
81+
/// it. A body with no locatable brace falls back to splicing before its last
82+
/// non-blank line.
83+
static func inject(_ block: String, into body: String,
84+
container name: String) -> String {
85+
var lines = lines(body)
86+
while let last = lines.last, last.allSatisfy(\.isWhitespace) {
87+
lines.removeLast()
88+
}
89+
guard !lines.isEmpty else { return body }
90+
// The line and character index of the container's closing brace — the
91+
// splice point. `column` stays `-1` until located, so an unlocatable
92+
// container falls back to inserting before the trimmed body's last line.
93+
var closer = lines.count - 1
94+
var column = -1
95+
if let primary = primary(name, in: Parser.parse(source: body)),
96+
let spot = locate(primary.close, in: body) {
97+
closer = spot.line
98+
column = spot.column
99+
}
100+
let characters = Array(lines[closer])
101+
let prefix = String(characters[0 ..< max(column, 0)])
102+
if column >= 0, !prefix.allSatisfy(\.isWhitespace) {
103+
// The closing brace shares its line with the declaration body: split the
104+
// line so the child nests between the body and the brace, keeping the
105+
// brace at the declaration's own indentation.
106+
let indent = String(prefix.prefix { $0 == " " || $0 == "\t" })
107+
let suffix = String(characters[column...])
108+
lines[closer] = prefix + "\n" + block + "\n" + indent + suffix
109+
} else {
110+
// A brace-only closing line (or no located brace): insert the child as a
111+
// whole line before it.
112+
lines.insert(block, at: closer)
113+
}
114+
return lines.joined(separator: "\n")
115+
}
116+
117+
/// Splits a value type's rendered `body` into the file-scope content before
118+
/// its primary declaration, the declaration itself (with its leading
119+
/// attributes and doc comments and its whole nested body), and the file-scope
120+
/// content after it. The primary declaration is the top-level `struct` or
121+
/// `enum` named `name`, located off the syntax tree; its leading run of
122+
/// attribute (`@`), comment (`//`), and blank lines is kept with it so an
123+
/// `@frozen` or doc comment is not stranded at file scope, and a trailing
124+
/// comment or blank on its own lines stays with it too — the footer begins at
125+
/// the first code line past its close. A body with no locatable
126+
/// `struct`/`enum` named `name` is treated as all declaration.
127+
static func partition(_ body: String, named name: String)
128+
-> (header: String, declaration: String, footer: String) {
129+
let lines = lines(body)
130+
guard let primary = primary(name, in: Parser.parse(source: body)),
131+
let head = locate(primary.leading, in: body),
132+
let tail = locate(primary.close, in: body) else {
133+
return ("", body, "")
134+
}
135+
// Keep the declaration's leading doc comments and blank lines with it — its
136+
// attribute list is already covered, the leading token being the first
137+
// attribute — so a `///` above the type, a block `/** … */`, or a multiline
138+
// `@available(…)` is not stranded at file scope apart from its declaration.
139+
// A block comment is absorbed by scanning backward from its closing `*/`
140+
// line to its opening `/*`, since its lines carry no `//`.
141+
var start = head.line
142+
var block = false
143+
while start > 0 {
144+
// Trim both ends, a trailing `\r` included — a CRLF `-I` override's split
145+
// lines keep the `\r`, so a block-doc closer reads `*/\r` and would fail
146+
// the `*/` recognition, stranding the comment in the file-scope header.
147+
let text = String(lines[start - 1].drop { $0 == " " || $0 == "\t" }
148+
.reversed().drop { $0.isWhitespace }.reversed())
149+
if block {
150+
start -= 1
151+
if text.hasPrefix("/*") { block = false }
152+
continue
153+
}
154+
if text.isEmpty || text.hasPrefix("//") { start -= 1; continue }
155+
if text.hasSuffix("*/") {
156+
start -= 1
157+
if !text.hasPrefix("/*") { block = true }
158+
continue
159+
}
160+
break
161+
}
162+
let header = lines[0 ..< start].joined(separator: "\n")
163+
// The footer is the file-scope content after the declaration. When code
164+
// follows the closing brace on its *own* line (`struct Foo {}; extension
165+
// Foo {}`), it splits at the brace's source column so the trailing
166+
// `extension` lands in the footer, not the declaration — a nesting caller
167+
// would otherwise indent it into the enclosing type, where a nested
168+
// `extension` is invalid. Otherwise the footer begins at the first code
169+
// line past the close; a trailing comment (`// end Foo`) or blank on the
170+
// type's own lines stays with it.
171+
let closer = Array(lines[tail.line])
172+
let column = min(tail.column, closer.count - 1)
173+
let suffix = column + 1 < closer.count
174+
? String(closer[(column + 1)...]) : ""
175+
let trailing = suffix.drop { $0 == " " || $0 == "\t" }
176+
if !trailing.isEmpty, !trailing.hasPrefix("//") {
177+
let declaration = (lines[start ..< tail.line]
178+
+ [String(closer[...column])]).joined(separator: "\n")
179+
let footer = tail.line + 1 < lines.count
180+
? ([suffix] + Array(lines[(tail.line + 1)...]))
181+
.joined(separator: "\n")
182+
: suffix
183+
return (header, declaration, footer)
184+
}
185+
// A trailing comment or blank on the primary's own lines stays with the
186+
// declaration only when nothing follows it. A comment run that reaches the
187+
// end of the body trails the primary (`struct Foo {}` then `// end Foo`),
188+
// but a comment above a *following* declaration (`struct Foo {}` then
189+
// `/// Bar` then `struct Bar {}`) is that declaration's leading
190+
// documentation and belongs to the footer, not folded into `Foo`.
191+
var end = tail.line + 1
192+
var scan = end
193+
while scan < lines.count {
194+
let content = lines[scan].drop { $0 == " " || $0 == "\t" }
195+
guard content.isEmpty || content.hasPrefix("//") else { break }
196+
scan += 1
197+
}
198+
if scan == lines.count { end = scan }
199+
let declaration = lines[start ..< end].joined(separator: "\n")
200+
let footer = end < lines.count
201+
? lines[end...].joined(separator: "\n") : ""
202+
return (header, declaration, footer)
203+
}
204+
205+
/// The declaration's leading token — its first attribute, modifier, or
206+
/// keyword — and closing member brace of the top-level type named `name`, or
207+
/// `nil` when the tree declares no such type at file scope. Any named
208+
/// member-bearing declaration is a candidate — a `struct` or `enum` value
209+
/// type, and a `protocol` (or `class`/`actor`) the nesting now permits as a
210+
/// child — matched by conforming to both `NamedDeclSyntax` (its `name`) and
211+
/// `DeclGroupSyntax` (its `memberBlock`); an `extension`, being unnamed, is
212+
/// excluded, so a trailing `extension` footer is not read as the primary.
213+
/// A leading helper declaration is likewise not it. A keyword-escaped
214+
/// name matches its bare spelling. The leading token, not the name, marks the
215+
/// declaration's start, so its whole attribute list — however many lines an
216+
/// `@available(…)` spans — stays with the type rather than stranding in the
217+
/// file-scope header.
218+
private static func primary(_ name: String, in tree: SourceFileSyntax)
219+
-> (leading: TokenSyntax, close: TokenSyntax)? {
220+
primary(name, among: tree.statements)
221+
}
222+
223+
private static func primary(_ name: String,
224+
among statements: CodeBlockItemListSyntax)
225+
-> (leading: TokenSyntax, close: TokenSyntax)? {
226+
func bare(_ text: Substring) -> Substring {
227+
var trimmed = text
228+
if trimmed.hasPrefix("`") { trimmed = trimmed.dropFirst() }
229+
if trimmed.hasSuffix("`") { trimmed = trimmed.dropLast() }
230+
return trimmed
231+
}
232+
let sourced = SyntaxTreeViewMode.sourceAccurate
233+
let wanted = bare(name[...])
234+
for statement in statements {
235+
guard case let .decl(declaration) = statement.item else { continue }
236+
// A top-level `#if os(Windows) … #endif` guarding the primary: descend
237+
// each clause so a conditionally-compiled declaration is still located.
238+
if let conditional = declaration.as(IfConfigDeclSyntax.self) {
239+
for clause in conditional.clauses {
240+
if case let .statements(inner)? = clause.elements,
241+
let found = primary(name, among: inner) {
242+
return found
243+
}
244+
}
245+
continue
246+
}
247+
guard let group = declaration.asProtocol(DeclGroupSyntax.self),
248+
let type = declaration.asProtocol(NamedDeclSyntax.self),
249+
bare(type.name.text[...]) == wanted else { continue }
250+
// A malformed body (`struct Foo {` with no close) yields a *missing*
251+
// `rightBrace` token the parser synthesizes at EOF; returning it would
252+
// trap the line-indexing splice and slice, so treat the declaration as
253+
// unlocatable and take the whole-body fallback instead.
254+
let close = group.memberBlock.rightBrace
255+
guard close.presence == .present else { return nil }
256+
return (declaration.firstToken(viewMode: sourced) ?? type.name, close)
257+
}
258+
return nil
259+
}
260+
261+
/// The zero-based line and character-column of `token`'s first code character
262+
/// within `body` — the syntax-tree position mapped back to an index into the
263+
/// original source, so a splice or slice preserves the body's exact bytes.
264+
/// The column counts `Character`s, matching a `\n`-split line's own indices.
265+
private static func locate(_ token: TokenSyntax, in body: String)
266+
-> (line: Int, column: Int)? {
267+
// Count Unicode *scalars*, breaking a line on the `\n` scalar, so a CRLF
268+
// (`\r\n`) — a single `Character` grapheme a `Character` scan would neither
269+
// split nor recognize — advances the line, its `\r` a trailing column of
270+
// the line just ended. This matches `lines(_:)`, which splits on the same
271+
// scalar and keeps the `\r`, so a column indexes the same position in both.
272+
let offset = token.positionAfterSkippingLeadingTrivia.utf8Offset
273+
var line = 0
274+
var column = 0
275+
var utf8 = 0
276+
for scalar in body.unicodeScalars {
277+
if utf8 == offset { return (line, column) }
278+
if scalar == "\n" { line += 1; column = 0 } else { column += 1 }
279+
utf8 += scalar.utf8.count
280+
}
281+
return utf8 == offset ? (line, column) : nil
282+
}
283+
284+
/// The lines of `body`, split on the `\n` scalar with the split preserved
285+
/// (`omittingEmptySubsequences: false`). A CRLF line keeps its trailing `\r`
286+
/// — the split consumes only the `\n` — so `joined(separator: "\n")` restores
287+
/// the exact bytes, while `partition`/`inject` trim the `\r` where they
288+
/// recognize a delimiter. A plain `String.split(separator: "\n")` scans
289+
/// `Character`s and would not split a CRLF body at all (`\r\n` is one
290+
/// grapheme), leaving the whole body an unpartitionable single line.
291+
private static func lines(_ body: String) -> Array<String> {
292+
body.unicodeScalars.split(separator: "\n", omittingEmptySubsequences: false)
293+
.map { String(String.UnicodeScalarView($0)) }
294+
}
295+
}

0 commit comments

Comments
 (0)