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