diff --git a/.gitignore b/.gitignore index cd2de67f..fad3ebe1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ PrecompiledLibraries/swift-syntax/.swiftpm/xcode/package.xcworkspace/contents.xc .derived-data-log-* .package.env Tests/Projects/SymbolTests/DerivedData +.omc diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MachOSwiftSection-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MachOSwiftSection-Package.xcscheme index 654b2126..fb7a6da5 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MachOSwiftSection-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MachOSwiftSection-Package.xcscheme @@ -1,6 +1,6 @@ ` 字段对所有 kind 都按 4-byte 偏移解析。 +- `MachOSwiftSection/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift` 两个 init 路径(Readable / ReadingContext) — 发现 `conditionalInvertibleProtocolsRequirementsCount` 当成**单个** UInt16 读且没有 4-byte align padding。 +- `swift-demangling/Sources/Demangling/Main/Demangle/Demangler.swift` — `read(where:)` 是 `demangleIdentifier` 的入口,`at: 0` 强烈暗示输入字符串的第一个字节就不是数字 / endIndex。 +- `swift-project/swift/include/swift/ABI/GenericContext.h` 上游 ABI 头文件 — 用 `TrailingObjects<...>` 列出顺序、`getNumConditionalInvertibleProtocolsRequirementCounts() = popcount(set.rawBits())`、`numTrailingObjects(GenericConditionalInvertibleProtocolRequirement) = counts.back().count`。 +- `swift-project/swift/include/swift/ABI/InvertibleProtocols.h` 和 `swift/Basic/MathUtils.h` — `rawBits()` 是底层位掩码,`popcount` 数二进制 1 的个数。 + +### 关键发现 + +1. 错误信息 `matchFailed(wanted: "(read test function to succeed)", at: 0)` 唯一来源是 `Demangler.demangleIdentifier()` 第一行 `scanner.read(where: { $0.isDigit })`。`at: 0` 说明 demangler 一开始就在 endIndex 或者首字节非数字 → 几乎可以肯定喂进 demangler 的就是垃圾字节,不是合法 mangled name。 +2. 走过 2 条错误假设(都被否定): + - **假设 A**: `self.specializer` 绑到了 outer image,跨 image 读 stdlib 候选描述符时偏移错位。实测发现 `existingSection(for: candidate.imagePath)` 已经把 self.machO 绑到 candidate 自己 image,该方向是 no-op。 + - **假设 B**: `invertedProtocols` kind 的 `paramMangledName` 字段本身没有合法 mangled name 指针。但 `GenericSpecializationTests` 里所有 invertedProtocols 相关 fixture (`invertedCopyableExposed` 等) 都过,说明 main `requirements` 数组里的 `invertedProtocols` entry 是正常的。 +3. 写复现测试 `swiftResultMakeRequestSucceeds` 直接驱动 stdlib 中的 `Swift.Result` makeRequest,稳定复现 `matchFailed at: 0`,排除上游(Wire / IPC / 绑定)路径,把问题压缩到 MachOSwiftSection 内部。 +4. 加诊断测试 `swiftResultGenericRequirementsDiagnostic`,逐条 dump 每个 requirement 的 `flags.raw`、`param.relativeOffset`、`content.raw`、mangled bytes,并 dump conditional 区域起始 48 字节原始内容,看到: + +``` +@5771128: 03 00 02 00 04 00 00 00 05 00 00 00 92 eb fe ff ... +direct[0] kind=protocol paramMangled="q_" demangle OK +direct[1] kind=invertedProtocols paramMangled="x" demangle OK +conditional[0] kind=sameShape (?!) paramMangled=garbage demangle FAIL ← 不该是 sameShape +conditional[1] kind=layout (?!) paramMangled=garbage demangle FAIL ← 不该是 layout +``` + +5. 对照 Swift ABI (`swift/include/swift/ABI/GenericContext.h`): + - Conditional 区域 trailing 顺序: `ConditionalInvertibleProtocolSet, ConditionalInvertibleProtocolsRequirementCount, TargetConditionalInvertibleProtocolRequirement`。 + - `numTrailingObjects(ConditionalInvertibleProtocolsRequirementCount) = popcount(set.rawBits())` — count 字段是 **数组**,长度等于 set 中置位 bit 数。 + - counts 是**累计**,最后一个 entry 是 total。 + - `TrailingObjects` 在每个段切换时按下一种类型的 `alignof` 自动塞 padding;`GenericRequirementDescriptor` 要求 4-byte 对齐。 +6. 用 ABI 规则重新切 Result 的字节布局: + +``` +@5771128 03 00 set = 3 = {Copyable, Escapable} +@5771130 02 00 count[0] = 2 (Copyable 累计) +@5771132 04 00 count[1] = 4 (累计到 Escapable 即 total) +@5771134 ?? ?? padding 2 字节 -> 4 byte 对齐 +@5771136 05 00 00 00 ... req[0] kind=invertedProtocols ✓ +@5771148 05 00 00 00 ... req[1] kind=invertedProtocols ✓ +@5771160 05 00 00 00 ... req[2] kind=invertedProtocols ✓ +@5771172 05 00 00 00 ... req[3] kind=invertedProtocols ✓ +``` + + MachOSwiftSection 旧实现从 @5771132 开始读 req[0](错位 4 字节: 漏读 1 个 count + 漏 2 字节 padding + 多读一字 align 误差),后续 4 个 12 字节 entry 全部错位,paramMangledName 当 RelativeDirectPointer 解析得到指向乱码内存的偏移,demangler 在第 0 字节炸。 +7. `Swift.Result` 触发但 `Array` / `Optional` / `Dictionary` 不触发的原因: 这些类型的 conditional invertible set 要么为空、要么 popcount=1。popcount=1 时只需读 1 个 count 且 cursor 落在 4 字节对齐位置,旧实现侥幸正确;popcount≥2 时 cursor 错位才暴露。`Result` 同时声明 conditional Copyable 和 Escapable,popcount=2,正好踩中。 + +### 候选方案 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| A: 在 `collectRequirements` / `buildAssociatedTypeRequirements` 用 `try?` 跳过 demangle 失败的 entry | 改动最小,只动 GenericSpecializer | 症状治疗;掩盖 conditional 区域读错的事实;后续真出问题难定位;且不修 `conditionalInvertibleProtocolsRequirements` 解析,该字段对外 API 永远是垃圾 | +| B: 在 collectRequirements 等处显式 `if kind == .invertedProtocols { continue }` 跳过 | 比 A 略稳一点 | 仍是症状治疗;真正的 conditional 区域 entry 本应都是 invertedProtocols,但被错位后 kind 才显示成 sameShape/layout — 这条 guard 对修过的代码反而错杀正常 entry | +| C: 在 `GenericContext.swift` 按 Swift ABI 正确解析 conditional 区域 — count 数组 + popcount + 4-byte align | 根因修复;conditionalInvertibleProtocolsRequirements 对外 API 也变正确;GenericSpecializer 不用动 | 改动 2 个 init 路径;需要确认 baseline 测试不回归 | +| D: 把 `conditionalInvertibleProtocolsRequirementsCount` 改成数组类型暴露 cumulative API | 最贴近 Swift ABI 语义 | breaking API change,baseline fixture、生成器、字段名都要改;收益相对 C 不大 | + +## 3. 最终方案 + +采用 **C**: 在 `MachOSwiftSection/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift` 两个 init 路径(Readable 和 ReadingContext)按上游 ABI 正确解析 conditional invertible 区域。 + +外部 API 保持不变 — `conditionalInvertibleProtocolsRequirementsCount: InvertibleProtocolsRequirementCount?` 字段名和类型都不动,只把它的含义从「单字段读出的值」改成「count 数组的最后一项,即累计 total」。这样: + +- 不破坏 baseline (`GenericContextBaseline.swift` 里所有 fixture 都是 0,没有 conditional invertible)。 +- `conditionalInvertibleProtocolsRequirements: [GenericRequirementDescriptor]` 字段对外可用且内容正确。 +- GenericSpecializer 不用任何 `try?` 防御 — 修过之后 conditional 区域里所有 entry 都是合法的 `invertedProtocols` kind,paramMangledName 解析也正常。 + +附带在 `Tests/SwiftInterfaceTests/GenericSpecializationTests.swift` 加一个回归测试 `swiftResultMakeRequestSucceeds`,直接用 stdlib `libswiftCore` 中的 `Swift.Result` 描述符跑 `makeRequest`,把这次的现场固化下来。 + +RuntimeViewer 那侧(本次任务的下游表现层)同时做两处小修复: + +- `RuntimeViewerCore/.../RuntimeSwiftSection.swift` `specializationRequest(forCandidateID:in:)` 显式按 candidate 自己的 image 构造 `GenericSpecializer`(对当前 case 是 no-op,但跨 image 时是必需的;同时加一行 error log 方便后续排查)。 +- `RuntimeViewerUsingAppKit/.../SpecializationViewModel.swift` catch 分支补 `reloadRowRelay.accept(row)`,让 outline view 在 inner request 失败时也把 placeholder 删掉(与成功路径对称)。 + +## 4. 实际执行与改动 + +### 改动清单 + +| 仓库 | 文件 | 操作 | 说明 | +|------|------|------|------| +| MachOSwiftSection | `Sources/MachOSwiftSection/Models/Generic/GenericContext.swift` | 修改 | 两个 init 路径都改成按 `popcount(set.rawValue)` 循环读 N 个 UInt16 count entry,保留最后一个作为 cumulative total,再 `cursor.align(to: 4)`,然后按 total 读 GenericRequirementDescriptor 数组 | +| MachOSwiftSection | `Tests/SwiftInterfaceTests/GenericSpecializationTests.swift` | 修改 | 在 `Make Request` suite 末尾新增 `swiftResultMakeRequestSucceeds`,通过 `allAllTypeDefinitions` 反查 stdlib `Swift.Result`,给 makeRequest,断言两个参数 `A` / `B` | +| RuntimeViewer | `RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeSwiftSection.swift` | 修改(待提交) | `specializationRequest(forCandidateID:in:)` 用 `matchedEntry.machO` 构造 candidate-bound `GenericSpecializer`,并加一行错误诊断 log | +| RuntimeViewer | `RuntimeViewerUsingAppKit/.../Specialization/SpecializationViewModel.swift` | 修改(已 commit + push,SHA `3e3736b`) | catch 分支补 `self.reloadRowRelay.accept(row)` | + +### 关键命令 + +```sh +# 触发复现并迭代调试 — MachOSwiftSection 内 Swift Package Manager +swift test --filter "swiftResultMakeRequestSucceeds" # 复现 +swift test --filter "swiftResultGenericRequirementsDiagnostic" # 诊断 (后已删除) + +# 修复验证 — 全套 +swift test --filter "GenericSpecializationTests|GenericContextTests" +# → 122 tests 全过 + +# RuntimeViewer 编译验证 +xcodebuild build -workspace MxIris-Reverse-Engineering.xcworkspace \ + -scheme RuntimeViewerCore -configuration Debug \ + -destination 'generic/platform=macOS' 2>&1 | xcsift +# → success / 0 errors / 0 warnings +``` + +### 验证 + +- `GenericSpecializationTests` + `GenericContextTests` 共 **122 个测试全过**,包括新增的 `swiftResultMakeRequestSucceeds`,旧 80 个 fixture 测试无回归。 +- `RuntimeViewerCore` SPM scheme Debug build 成功,0 错误 0 警告。 +- UI 端实测(用户复现): 在 SwiftUICore 中选 `Swift.Result` → inner request 不再炸,正常展开 `Success` / `Failure` 两个子参数行。 + +### 与原方案的差异 + +执行过程一共绕了 2 次弯路,最终落点跟 Phase 3 列出的 **C** 完全一致;偏差是过程性的,不影响最终结果: + +- **偏差 1**: 最早 commit `8c1fd1c` 在 RuntimeViewer 侧加了 "specializer 绑 candidate 自己 image" 的修复,以为是 root cause。 + - **原因**: 误把 commit `f0c272e` 只修了 `resolveUpstreamArgument` 这件事推广到 `specializationRequest(forCandidateID:in:)`,但实际后者由 `swiftSectionFactory.existingSection(for: candidate.imagePath)` 已经把 self.machO 绑到 candidate image — 当前路径上该修复是 no-op。 + - **处理**: revert 该 commit(`1802dfc`),之后又作为防御性 + 诊断 log 重新加回(语义更明确,跨 image 时依然正确)。 + +- **偏差 2**: 中间一度怀疑 invertedProtocols kind 的 paramMangledName 是 garbage,在 GenericSpecializer 的 `collectRequirements` / `buildAssociatedTypeRequirements` 加了 skip。 + - **原因**: 看到诊断输出中 conditional[0]/[1] 的 kind 不是 invertedProtocols,误以为 paramMangledName 字段对这些 kind 不安全。 + - **处理**: 现有 fixture 测试 `invertedCopyableExposed` 等仍过 + 用户报告"还是不行" → 立即撤回。最终定位是 conditional 区域**整体错位**,所有 entry kind 都被错读;根因修了之后这条 guard 不再需要。 + +- **真正根因(C 方案)**: 命中 Swift ABI `numTrailingObjects(ConditionalInvertibleProtocolsRequirementCount) = popcount(set)` + `TrailingObjects` 自动 4-byte align。 + +## 5. 修复细节:为什么这样改 + +### 关键术语 + +- **`rawBits()`** (`swift/include/swift/ABI/InvertibleProtocols.h:59`): `InvertibleProtocolSet` 底层是一个整数位掩码,`Copyable = bit 0`、`Escapable = bit 1`。 + | 集合内容 | bits 二进制 | rawBits 十进制 | + |---|---|---| + | `{}` | `0b00` | 0 | + | `{Copyable}` | `0b01` | 1 | + | `{Escapable}` | `0b10` | 2 | + | `{Copyable, Escapable}` | `0b11` | 3 | + +- **`popcount`** (`swift/include/swift/Basic/MathUtils.h:42`): "Population Count" — 数二进制里 1 的个数。Swift 端对应 `Int.nonzeroBitCount`。 + | value | popcount | + |---|---| + | `0b00` | 0 | + | `0b01` | 1 | + | `0b11` | 2 | + +- **组合语义**: `popcount(set.rawBits())` = **set 里到底装了几个 invertible 协议**。C++ 那边没现成的 `count()`,所以用位运算直接数。 + +- **4-byte 对齐的来源**: 对齐**不在那段 C++ 显式代码里**,是 LLVM `TrailingObjects<...>` 模板在每个段切换时按下一种类型的 `alignof` 自动塞 padding 实现的。Conditional 区域顺序是 `Set (alignof 2) → Count[] (alignof 2) → GenericRequirementDescriptor (alignof 4)`,所以 Count 数组结束后若停在 2-byte 边界,自动塞 2 字节 padding 把 cursor 推到 4-byte 边界。 + +### Cursor 演进对照 + +``` +修复前: 5771128 -- +2(set) --> 5771130 -- +2(count) --> 5771132 <-- 从这儿读 req[0] ❌ +修复后: 5771128 -- +2(set) --> 5771130 -- +2(count[0]) --> 5771132 -- +2(count[1]) --> 5771134 -- align4 --> 5771136 <-- 从这儿读 req[0] ✓ +``` + +旧实现的 cursor 比正确位置**少 4 字节**(缺 1 个 count 字段 = 2 字节 + 缺 2 字节 padding),后续每个 12 字节 entry 整段错位 4 字节读取。 + +### 三处具体改动各自治什么病 + +| # | 改动 | 治什么 | +|---|------|--------| +| 1 | 把 `count` 从单字段读改成循环读 `popcount(set)` 个 UInt16 | `{Copyable, Escapable}` 时 count 数组有 2 个 UInt16,少读 1 个就少走 2 字节 | +| 2 | 取最后一个 count 作为 cumulative total | Swift ABI 规定 counts 是累计的(`GenericContext.h:583-587` "The counts are cumulative … the last entry is, therefore, the total count");旧实现拿第一个当 total,popcount > 1 时数量本身就错 | +| 3 | `currentOffset.align(to: 4)` 在读 GenericRequirementDescriptor 之前 | TrailingObjects 在 2-byte 段(Count[])和 4-byte 段(GenericRequirementDescriptor)之间会插 padding,Swift 端必须跟着 align,否则即使 #1#2 都修了仍然错位 2 字节 | + +### 修复前 vs 修复后读到的 req[0] 内容 + +``` +共享字节: 05 00 00 00 92 eb fe ff ff ff fe ff (req[0] 真实的 12 字节) + +修复前从 5771132 当 req[0] 起点解析: + flags = 04 00 00 00 = 0x00000004 -> kind = 4 = sameShape (其实是 count[1]) + param = 05 00 00 00 = 5 (其实是 padding + req[0].flags 低位) + content= 92 eb fe ff = -70766 (其实是 req[0].param) + -> paramMangledName 解析时跳到无意义内存,读到 0xeb 0xfe 0xfe ... 当 mangled name + -> demangler 第 0 字节 (0xeb) 既不在 switch case 列表也不是 digit -> matchFailed(at: 0) ✗ + +修复后从 5771136 当 req[0] 起点解析: + flags = 05 00 00 00 -> kind = 5 = invertedProtocols ✓ + param = 92 eb fe ff (relativeOffset = -70766) + content= ff ff fe ff (interpreted as InvertedProtocols struct) + -> paramMangledName 跳到合法 mangled name buffer ("x" 或 "q_") + -> demangler 正常构造 Type 节点 ✓ +``` + +至此 root cause 被精准修复在 ABI 层,GenericSpecializer 不再需要任何防御性 guard。 diff --git a/Documentations/TaskReports/2026-05-16-fix-swiftinterface-print-path-dag-explosion.md b/Documentations/TaskReports/2026-05-16-fix-swiftinterface-print-path-dag-explosion.md new file mode 100644 index 00000000..7c5be2d8 --- /dev/null +++ b/Documentations/TaskReports/2026-05-16-fix-swiftinterface-print-path-dag-explosion.md @@ -0,0 +1,96 @@ +# 2026-05-16 - Fix SwiftInterface print path DAG explosion + +- **日期**: 2026-05-16 +- **任务**: Fix SwiftInterface print path DAG explosion +- **作者**: Mx-Iris +- **仓库**: https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection.git + +## 1. 问题 / 任务 + +用户反馈集成测试 `SwiftInterfaceBuilderTestSuite.DyldCacheTests.buildFile` 在加载 `dyld_shared_cache_arm64e` 中的 `SnippetUI` 镜像时出现"死循环"。提供堆栈日志 `/Users/JH/Desktop/swift-section-backtrace-all.log`,要求查清根因并修复,参考 Apple Swift 源码做法。 + +## 2. 探索与调研 + +### 调研内容 + +- 读取并分析堆栈日志 `swift-section-backtrace-all.log`(423 行,含 ~500 帧深的 `BoundGenericNodePrintable` 嵌套) +- 查看测试入口 `Tests/IntegrationTests/SwiftInterface/SwiftInterfaceBuilderTests.swift` +- 查看打印协议链:`Sources/SwiftInterface/NodePrintables/{NodePrintable,InterfaceNodePrintable,TypeNodePrintable,BoundGenericNodePrintable}.swift` +- 查看 4 个 conformer:`Sources/SwiftInterface/NodePrinter/{Type,Function,Variable,Subscript}NodePrinter.swift` +- 查看 dump 入口 `Sources/SwiftDump/Dumper/AssociatedTypeDumper.swift`(`mergedRecords` + `OpaqueTypeRewriter` + `OpaqueTypeGenericParameterRewriter`) +- 查看 `swift-demangling/Sources/Demangling/Node/Node+Rewriter.swift`(post-order rewrite,`visit` 返回值不再 rewrite) +- 查看 `swift-demangling/Sources/Demangling/Node/Printer/{NodePrinter,NodePrinterTarget}.swift` +- 查看 `swift-semantic-string/Sources/Semantic/SemanticString.swift`(`append(_:)`、`subscript(range:)`、`components` lazy fold) +- 查看 `MetadataReader.demangleType` 与 `MetadataReaderCache`、`MangledName` 解析 +- 在 `mergedRecords` 与 `OpaqueTypeRewriter.visit` 中加诊断日志(三轮迭代,最后用文件 logger `AssociatedTypeDumperDiagLogger` 绕过 swift testing 的 stdout buffer),跑 `swift test --filter "SwiftInterfaceBuilderTestSuite.DyldCacheTests/buildFile"` 复现 +- 用 `sample ` 多次抓取卡住进程的堆栈,确认嵌套位置始终在 `BoundGenericNodePrintable.printBoundGenericNoSugar` → `printChildren` → `printSequence` → `printName` → `printNameInType` → `printFirstChild` → `printOptional` → `printName` 循环 +- 阅读 Apple 源码 `swift/lib/Demangling/NodePrinter.cpp`(`MaxDepth` 检查 line 1416)、`swift/include/swift/Demangling/Demangle.h`(`MaxDepth = 768` line 909)、`swift/lib/Demangling/Demangler.cpp`(`demangleMultiSubstitutions` 直接返回共享 `NodePointer`,line 1198)、`swift/include/swift/Demangling/ManglingUtils.h`(`MaxRepeatCount = 2048`) + +### 关键发现 + +- 卡住 record 的诊断数据:`name=Body`、`mangledNameSize=320`(字节流 240 字节)、demangle 后 `depth=41 / unique=246 / maxRepeat=19`、`OpaqueTypeRewriter` 解析 3 个 opaque type 后 `depth=41 / unique=255 / maxRepeat=19` +- mangle name 中含 11 个 `0x02` symbolic-reference 字节 + 17 个 substitution 反向引用(`AC / AJ / AK / AP / AR / AT / AV / AY / A0_ … A25_`),`A25_` 已经回引到第 25 个 substitution +- demangle 出的不是 tree 而是 **DAG**:substitution 共享同一个 `Node` 实例(来自 swift-demangling `Demangler` 的设计,与 Apple 行为一致) +- `TypeNodePrinter` / `NodePrintable` 链按 `children` 朴素递归,没有共享节点去重 → DAG 被当 tree 完整展开 → `_measureExpansion` 实测 **394 062 节点访问** +- 这不是真正的死循环:22 分钟 CPU 仍在跑、`<>` fallback 始终未触发,单条 root-to-leaf 路径深度只有 ~100,远小于任何合理阈值 +- Apple `swift demangle` 命令面对同样 mangle 也会卡——它只在单条路径深度 > 768 时输出 `<>` 兜底,对 DAG 共享展开慢这件事**没有处理** +- `swift-demangling` Swift port 的 `NodePrinter` 也没有 MaxDepth 兜底,但本仓库的死循环路径不经过它(仓库自定义 `TypeNodePrinter`) +- `SemanticString` 天然支持 `append(_:)` 与 `subscript(range:)`,可以用作 memoization 的缓存值;`NodePrinterTarget` 协议本身没有 append API,需要在仓库内通过 `where Target == SemanticString` 限定 +- 4 个 conformer (`TypeNodePrinter` / `FunctionNodePrinter` / `VariableNodePrinter` / `SubscriptNodePrinter`) 全部使用 `SemanticString` 作为 `Target`,约束不会破坏现有代码 + +### 候选方案 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| A. Print 阶段 visited 集合 + 占位符 | 实现最简,能彻底防死循环 | 共享类型只展开第一次,后续输出占位符,类型显示不完整 | +| B. SemanticString memoization(首次完整 print + 缓存片段,后续复用) | 输出完整、O(unique nodes) 复杂度、兼具兜底功能 | 需要给 NodePrintable 加缓存状态 + 限定 `Target == SemanticString` | +| C. `mergedRecords` 局部 expansion-size cap + 降级输出 | 最小改动、不影响其它路径 | 仅保护 `mergedRecords` 一处入口,覆盖面窄 | +| D. Apple-style MaxDepth=768 单一兜底(仅项 1 完成) | 与 Apple 完全一致、抗病态深嵌套 | 当前 case 路径深度只有 ~100,触不到阈值,对本死循环无效 | + +## 3. 最终方案 + +用户最终选 **方案 B + 同时保留 Apple-style MaxDepth 兜底(D)**,思路: + +1. 模仿 Apple `swift/include/swift/Demangling/Demangle.h:909` 与 `NodePrinter.cpp:1416` 加 `maxPrintDepth = 768` 单条递归路径深度兜底,超阈值输出 `<>` 并 return(防御未来病态 mangle) +2. 在 `InterfaceNodePrintable.printName` 入口加 DAG memoization: + - 缓存键 `ObjectIdentifier(name)`,缓存值 `SemanticString` + - 仅在 default-context (`!asPrefixContext && context == nil && dependentMemberTypeDepth == 0`) 下读写缓存,避免 context-dependent 输出污染 + - 命中:`target.append(cached)` 直接返回 + - 未命中:`swap(&target, &subTarget)` 重定向到 fresh sub-target,dispatch 完成后写回主 target 并把 sub-target 缓存 +3. 把缓存类型与 `target.append(_:)` 操作收进 `extension InterfaceNodePrintable where Target == SemanticString`,4 个 conformer 都满足约束 +4. 4 个 conformer 各加 `var printDepth: Int = 0` + `var printCache: [ObjectIdentifier: SemanticString] = [:]` stored property +5. 不改 `swift-demangling`:Demangler 的 substitution 共享行为与 Apple 一致,是正确设计;该模块的 NodePrinter 也未参与本死循环路径 + +## 4. 实际执行与改动 + +### 改动清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `Sources/SwiftInterface/NodePrintables/NodePrintable.swift` | 修改 | 加 `import Semantic`;协议加 `var printDepth: Int { get set }` 与 `var printCache: [ObjectIdentifier: SemanticString] { get set }`;extension 提供 `static var maxPrintDepth: Int { 768 }`,注释指向 Apple `Demangle.h:909` | +| `Sources/SwiftInterface/NodePrintables/InterfaceNodePrintable.swift` | 修改 | `printName` 实现限定 `where Target == SemanticString`;入口先做 `printDepth > maxPrintDepth` 兜底输出 `<>`;default-context 下走 cache 命中复用或 `swap(&target, &subTarget)` 捕获子 target 后缓存;非 default-context 走原 dispatch;原 dispatch 抽到 `private mutating func dispatchPrintName` | +| `Sources/SwiftInterface/NodePrinter/TypeNodePrinter.swift` | 修改 | 加 `var printDepth: Int = 0` + `var printCache: [ObjectIdentifier: SemanticString] = [:]` | +| `Sources/SwiftInterface/NodePrinter/FunctionNodePrinter.swift` | 修改 | 同上 | +| `Sources/SwiftInterface/NodePrinter/VariableNodePrinter.swift` | 修改 | 同上 | +| `Sources/SwiftInterface/NodePrinter/SubscriptNodePrinter.swift` | 修改 | 同上 | + +`git diff --stat` 输出:6 files changed, 80 insertions(+), 1 deletion(-)。 + +### 关键命令 + +``` +swift test --filter "SwiftInterfaceBuilderTestSuite.DyldCacheTests/buildFile" +swift test --skip IntegrationTests +``` + +调研期间还使用 `sample ` 多次抓取卡住进程的堆栈以及 `MetadataReader.demangleType` + `OpaqueTypeRewriter` 加临时诊断日志(已清理,最终改动只剩上面 6 个文件)。 + +### 验证 + +- 卡死的 case:`Test buildFile() passed after 5.563 seconds.`(之前 22+ 分钟 CPU 仍跑不完) +- 完整测试套件:`Test run with 1003 tests in 191 suites passed after 78.061 seconds.`(0 失败) +- `<>` 兜底在该 case 中没有触发——证明 memoization 已经把 print 复杂度从 394 062 节点访问降到 ~unique 节点级别,单条路径远未触及 768 阈值 + +### 与原方案的差异 + +无,与最终方案一致。调研期为了诊断在 `AssociatedTypeDumper.swift` 临时加过 `_logFlushed` / `_measureNode` / `_measureExpansion` / `AssociatedTypeDumperDiagLogger` 等辅助代码,定位到根因后已全部还原(`git diff Sources/SwiftDump/` 为空),最终落库的改动只在 `Sources/SwiftInterface/` 的 6 个文件内。 diff --git a/Package.swift b/Package.swift index 88e12454..fb479d96 100644 --- a/Package.swift +++ b/Package.swift @@ -227,7 +227,7 @@ extension Package.Dependency { ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/swift-demangling", - from: "0.3.0" + from: "0.4.0" ) ) @@ -455,9 +455,6 @@ extension Target { .target(.SwiftDump), .target(.Utilities), ], - exclude: [ - "GenericSpecializer/REVIEW_FIXUPS.md", - ] ) static let TypeIndexing = Target.target( @@ -567,6 +564,8 @@ extension Target { .target(.MachOReading), .target(.MachOResolving), .target(.MachOFixtureSupport), + .target(.MachOSwiftSection), + .target(.SwiftInterface), ], swiftSettings: testSettings ) diff --git a/Roadmaps/2026-05-11-bound-generic-candidates.md b/Roadmaps/2026-05-11-bound-generic-candidates.md new file mode 100644 index 00000000..558a8969 --- /dev/null +++ b/Roadmaps/2026-05-11-bound-generic-candidates.md @@ -0,0 +1,288 @@ +# Bound Generic Candidates in GenericSpecializer — 2026-05-11 + +Spec for letting `GenericSpecializer` accept `Array` / `Dictionary` style "已绑定具体类型实参的泛型" as the selected type for an outer generic parameter (e.g. `Outer` with `T = Array`), without forcing callers to hand-build the inner `SpecializationResult` themselves. + +All file/line references are against the state of the repo on 2026-05-11. + +--- + +## Current behaviour + +Existing infrastructure (under `Sources/SwiftInterface/GenericSpecializer/`): + +- `SpecializationRequest.Candidate` already exposes `isGeneric: Bool` (`Models/SpecializationRequest.swift:180`). +- `findCandidates` (`GenericSpecializer.swift:551`) includes generic candidates by default; callers can drop them via `CandidateOptions.excludeGenerics`. +- `resolveCandidate` (`GenericSpecializer.swift:1325`) throws `SpecializerError.candidateRequiresNestedSpecialization` when a generic candidate is supplied through `Argument.candidate(...)`, instructing the user to switch to `Argument.specialized(SpecializationResult)`. +- `Argument.specialized` is functional today — `Tests/SwiftInterfaceTests/GenericSpecializationTests.swift:906` and the "nested generic specialize() end-to-end" suite exercise it — but requires the caller to: + 1. Build the inner `SpecializationRequest` themselves via a second `makeRequest`. + 2. Call `specialize` on the inner generic. + 3. Wrap the resulting `SpecializationResult` into `.specialized(...)` and pass it to the outer selection. + +The candidate list surfaced by `makeRequest` therefore shows `Array` (generic) but never `Array` (bound); UI layers either filter the bound-generic case out via `excludeGenerics` or replicate the three-step ceremony themselves. + +## Goal + +Make `Array` and arbitrarily nested forms (`Dictionary>`, etc.) a first-class selection that flows through the same `SpecializationSelection` API as the leaf cases, with no manual `specialize` ping-pong. + +--- + +## Solution space + +| # | Approach | Selection shape | Recursion handling | Status | +|---|---|---|---|---| +| 1 | Helper that turns `(genericCandidate, innerSelection)` into a `SpecializationResult` and re-wraps it as `Argument.specialized` | unchanged | caller-driven loop | dropped — subsumed by 2 | +| 2 | New `Argument.boundGeneric(baseCandidate:innerArguments:)` case in `SpecializationSelection` | declarative tree | specializer recurses internally | **active** | +| 3 | New `Argument.mangled(MangledName)` / `Argument.typeNode(Node)` resolved through `swift_getTypeByMangledNameInContext` | flat, externally-typed | runtime handles substitution | TODO | +| 4 | `Candidate` carries a lazy `nestedRequest`; UI auto-expands; `SpecializationSelection` becomes tree-shaped end to end | tree | automatic | TODO | + +Approaches 3 and 4 are independent of 2 and target different consumer profiles (3 = "any type the runtime can mangle", 4 = "UI walks request trees"). They are scoped as follow-ups in their own sections at the bottom of this document. + +--- + +## Approach 2 — `Argument.boundGeneric` (active) + +### Modification 2-1. `Models/SpecializationSelection.swift` + +Extend the `Argument` enum with one new case (current cases live at lines 35–44): + +```swift +public enum Argument: @unchecked Sendable { + case metatype(Any.Type) + case metadata(Metadata) + case candidate(SpecializationRequest.Candidate) + case specialized(SpecializationResult) + /// Bind a generic candidate (e.g. `Array`, `Dictionary`) to a + /// nested selection. The specializer recursively builds an inner + /// `SpecializationRequest` from `baseCandidate`'s descriptor and + /// substitutes `innerArguments`; the resulting metadata feeds the + /// outer key-arguments buffer in place of a concrete leaf type. + case boundGeneric( + baseCandidate: SpecializationRequest.Candidate, + innerArguments: [String: Argument] + ) +} +``` + +Extend `SpecializationSelection.Builder` with a matching convenience: + +```swift +@discardableResult +public func set( + _ parameterName: String, + to candidate: SpecializationRequest.Candidate, + boundTo innerArguments: [String: Argument] +) -> Builder +``` + +No change to `subscript(parameterName:)`, `hasArgument(for:)`, or the dictionary literal initializer. + +### Modification 2-2. `GenericSpecializer.swift` switch sites + +Five switches in `GenericSpecializer.swift` need a new branch. Behaviour is consistent across all of them: `.boundGeneric` should behave **as if** the caller had separately built and supplied an `Argument.specialized(...)` for the same inner selection, with the only difference being that the recursion is now driven by the specializer. + +| # | Location | Current behaviour | `.boundGeneric` behaviour | +|---|---|---|---| +| (a) | `resolveMetadata` — `GenericSpecializer.swift:1308` | `.candidate` → `resolveCandidate`; `.specialized` → `result.metadata()` | Build inner `SpecializationRequest` from `baseCandidate.typeName`'s descriptor, wrap `innerArguments` into a `SpecializationSelection`, recursively call `specialize(_:with:)`, return `result.metadata()` | +| (b) | `runtimePreflight` pre-pass — `GenericSpecializer.swift:720–762` | `.candidate` skipped; `.metatype` / `.metadata` / `.specialized` populate `metadataByName` | Recursively run `validate` + `runtimePreflight` on the inner selection, then call `try inner.metadata()` and insert into `metadataByName`. Failures are aggregated as `metadataResolutionFailed` carrying a dotted path prefix (`"A.B"`) | +| (c) | `runUnifiedConstraintCheck` bail-out — `GenericSpecializer.swift:890–894` | Any `.candidate` causes the whole pass to bail (candidate accessors are side-effectful in preflight) | `.boundGeneric` does **not** trigger the bail — it can produce a final metadata for `swift_getTypeByMangledNameInContext` substitution, same as `.specialized`. Only naked `.candidate` continues to bail | +| (d) | `validate` — `GenericSpecializer.swift:644` | Checks missing/extra arguments and associated-type-path warning | For `.boundGeneric`, recursively `validate` the inner selection against the inner request; flatten errors and warnings with dotted parameter paths | +| (e) | `SpecializerError` — `GenericSpecializer.swift:1668` | — | Add `case boundGenericInnerFailed(parameterName: String, underlying: Error)` so inner failures keep their typed identity instead of being string-collapsed | + +#### (a) Recursion contract + +Inner request construction uses the existing path: + +```swift +let innerTypeDescriptor = try resolveCandidateDescriptor(baseCandidate) +let innerRequest = try makeRequest(for: innerTypeDescriptor) +let innerSelection = SpecializationSelection(arguments: innerArguments) +let innerResult = try specialize(innerRequest, with: innerSelection) +return try innerResult.metadata() +``` + +`resolveCandidateDescriptor` factors out the indexer lookup currently embedded in `resolveCandidate` (`GenericSpecializer.swift:1325-1353`). The "candidate is itself generic" branch that today throws `candidateRequiresNestedSpecialization` becomes the **expected** path for `.boundGeneric`; the bare-`.candidate` path keeps throwing. + +#### (b) Preflight semantics + +The pre-pass must not partially advance state. If inner preflight reports any error, the outer pass: + +1. Records a single `metadataResolutionFailed(parameterName: "", reason: "")` against the outer builder. +2. Does **not** insert anything into `metadataByName` for that parameter — keeping cross-parameter checks (sameType GP-vs-GP) consistent with how `.candidate` is treated. + +Inner warnings are forwarded with a `"." prefix` so the surface remains debuggable. + +#### (c) Constraint-check participation + +Because `.boundGeneric` can be resolved to a concrete `Any.Type` before `runUnifiedConstraintCheck` runs, the runtime substitution path (`swift_getTypeByMangledNameInContext`) sees a fully-formed metadata in the arguments buffer. The check is therefore strictly stronger than the current `.specialized`-only support: a `where T == Array` constraint on the outer signature can now be validated even when the user spelled `T` through `.boundGeneric`. + +#### (d) Static validation recursion + +`validate` was previously cheap and synchronous; recursion keeps that property as long as the inner request can be built without descriptor resolution. To avoid forcing descriptor lookups in the static pass: + +- If the inner request can be cached on the outer `Candidate` (Approach 4 direction), reuse it. +- Otherwise build the inner request lazily inside `validate` and cache it on a per-call basis. Failure to build the inner request becomes a structural error (`boundGenericInnerFailed` with a `requestConstructionFailed` underlying case). + +### Modification 2-3. `SpecializationResult.ResolvedArgument` → tree (Decision: B) + +`ResolvedArgument` currently flattens to `(parameterName, metadata, witnessTables)`. Switch to **tree shape**: add an optional `innerResult` field that captures the recursively-resolved inner `SpecializationResult`. + +```swift +public struct ResolvedArgument: @unchecked Sendable { + public let parameterName: String + public let metadata: Metadata + public let witnessTables: [ProtocolWitnessTable] + /// Present when the argument came from `Argument.boundGeneric` or + /// `Argument.specialized`; nil for `metatype` / `metadata` / + /// non-generic `candidate`. Walks the binding tree. + public let innerResult: SpecializationResult? + + public init( + parameterName: String, + metadata: Metadata, + witnessTables: [ProtocolWitnessTable] = [], + innerResult: SpecializationResult? = nil + ) { ... } +} +``` + +Rationale: + +1. The specializer is currently SPI (`@_spi(Support)`), so the API impact is bounded. +2. External consumers — including the existing snapshot/builder paths — already inspect `resolvedArguments` to render nested types in the interface output. Discarding the inner tree would force them to re-derive it from the original `SpecializationSelection`, duplicating work and decoupling rendering from what the runtime actually produced. +3. `.metatype` / `.metadata` keep `innerResult == nil`; the change is purely additive for those cases. + +### Modification 2-4. PWT ordering invariant + +Bound-generic parameters do not introduce new key-argument slots in the **outer** signature: the outer accessor still expects exactly one metadata pointer per outer generic parameter, plus the outer's own PWTs. Inner PWTs are consumed by the inner `specialize` call's own accessor invocation and never bleed into the outer arguments buffer. This preserves the canonical-order invariant documented at `GenericSpecializer.swift:1224-1240` (the `compareDependentTypesRec` ordering): `buildKeyArgumentsBuffer` does not need any change beyond reading the new metadata from `resolveMetadata`'s recursion. + +### Modification 2-5. Recursion termination & cycle safety + +`.boundGeneric` chains terminate when an inner `Argument` is one of: + +- `.metatype` / `.metadata` (concrete metadata, no further work), +- `.candidate` referencing a **non-generic** type descriptor (single accessor call), +- `.specialized` (already-resolved tree). + +Cycles like `Array>>` cannot be constructed without an explicit user-built tree of `.boundGeneric` cases — each level requires the caller to commit to a concrete leaf at the bottom. Defensive depth-limit guards are **not** required for correctness, but a configurable `maxBindingDepth` (default 16) on `GenericSpecializer` is a low-cost ergonomic guard against runaway recursion from buggy callers; emit `SpecializerError.specializationFailed(reason: "binding depth exceeded")` when crossed. + +### Verification + +New tests live next to `Tests/SwiftInterfaceTests/GenericSpecializationTests.swift`: + +1. **`Outer = Array`** — `.boundGeneric(Array, ["A": .metatype(Int.self)])` resolves and matches the manually-built `.specialized` path's metadata pointer. +2. **`Outer = Dictionary>`** — two-level nested `.boundGeneric`; result equals the manually-staged equivalent. +3. **PWT slot count parity** — running the new path through `buildKeyArgumentsBuffer` produces `metadatas.count + witnessTables.count == request.keyArgumentCount` (existing invariant at `GenericSpecializer.swift:1297-1302`). +4. **`runtimePreflight` catches mismatched conformance** — `.boundGeneric` of a generic whose substituted result fails a protocol requirement reports `protocolRequirementNotSatisfied`, not a stringified inner error. +5. **`runUnifiedConstraintCheck` validates `where T == Array`** — the constraint passes when `T` is supplied via `.boundGeneric(Array, ["A": .metatype(Int.self)])` and fails (with a typed error) when supplied as `.boundGeneric(Array, ["A": .metatype(String.self)])`. +6. **Inner failure surfaces typed** — supplying an inner argument that violates an inner requirement produces `boundGenericInnerFailed` with a recognizable `underlying` chain, not a flat string. +7. **`ResolvedArgument.innerResult` is populated** — for `.boundGeneric` and `.specialized` selections, the inner tree is reachable; for `.metatype` / `.metadata`, `innerResult == nil`. + +### Effort + +Medium. Largest single piece is the recursion in `resolveMetadata` plus matching adjustments in `runtimePreflight` / `runUnifiedConstraintCheck` / `validate`. The Models change and the tree-shaped `ResolvedArgument` are straightforward. + +### Risks + +- **Preflight cost.** Recursive validation pays for one full `validate` + `runtimePreflight` per binding level. The cost is bounded by user-built tree depth; acceptable for the SPI's interactive use cases. If batch consumers appear, add a memoization key on `(descriptor, hashedInnerArguments)`. +- **Error message clarity.** Without dotted path prefixes, errors at depth 3 read as "missing argument for `A`" with no indication of which generic level. The dotted-path convention above mitigates this; verify in test 6's snapshot. +- **API churn.** `ResolvedArgument.innerResult` adds a stored field. Because the specializer is SPI, this is acceptable; revisit if the API is ever promoted to stable. + +--- + +## Approach 3 — runtime-direct mangled / node arguments (TODO) + +Sketch retained here so the work isn't lost; not started. + +### Motivation + +`.boundGeneric` can only target types whose descriptors are known to the indexer. Function types (`(Int) -> String`), tuples (`(Int, String)`), and stdlib types not in any sub-indexer cannot be selected by descriptor — but the Swift runtime can resolve them from a mangled name via `swift_getTypeByMangledNameInContext`, which the specializer already uses in `runUnifiedConstraintCheck` (`GenericSpecializer.swift:1031`). + +### Direction + +Add to `SpecializationSelection.Argument`: + +```swift +case mangled(MangledName) +case typeNode(Node) +``` + +`.typeNode` re-mangles through `Remangler` before handing the bytes to the runtime. Internal resolution path: + +```swift +case .mangled(let mangledName): + guard let resolvedType = try RuntimeFunctions.getTypeByMangledNameInContext( + mangledName, + genericContext: ..., // pass an empty / type-free context + genericArguments: ..., + in: machO + ) else { throw SpecializerError.specializationFailed(...) } + return try Metadata.createInProcess(resolvedType) +``` + +### Open questions + +- **Generic context for substitution.** `getTypeByMangledNameInContext` accepts a generic context — for free-standing arguments (no outer GPs in the name), passing `nil` may or may not work for every reachable type. Empirical sweep required. +- **PWT derivation.** When the mangled type satisfies a protocol requirement on the outer parameter, the runtime gives back `Any.Type`, not the witness table. `resolveWitnessTable` (`GenericSpecializer.swift:1371`) already covers this via `RuntimeFunctions.conformsToProtocol` — reuse without modification. +- **Error surface.** Runtime resolution failures return `nil`/throw with low-detail context; expose a typed `SpecializerError.mangledArgumentResolutionFailed(mangledName:, reason:)` so callers can distinguish from descriptor-level failures. +- **UI dependency.** Callers need a way to produce mangled names or `Node` trees. Either expose a helper on `Demangling.Remangler` or document a recommended path (e.g. demangle a user-supplied string and feed the resulting `Node` to `.typeNode`). + +### Risks + +- Loss of static introspection. `.mangled` arguments do not expose a descriptor, so the candidate-list view cannot show them with the same metadata as descriptor-based candidates. +- Runtime trust boundary widens: arbitrary mangled bytes flow into `swift_getTypeByMangledNameInContext`. Add input validation (demangle round-trip) before invoking the runtime. + +### Effort + +Medium. The runtime call already exists; the work is in API surface, error handling, and Remangler ergonomics. + +--- + +## Approach 4 — tree-shaped `Candidate` with lazy nested requests (TODO) + +Sketch retained here; not started. + +### Motivation + +Approach 2 puts the recursion in `SpecializationSelection` but leaves `Candidate` flat — UI layers that want to show "expandable" generic candidates (`Array` → "open" → pick `Element`) must build the inner request themselves by calling `makeRequest` on the candidate's descriptor. Approach 4 hoists that into the candidate. + +### Direction + +Promote `Candidate` to carry an optional lazy nested request: + +```swift +public struct Candidate: Sendable, Hashable { + public let typeName: TypeName + public let source: Source + public let isGeneric: Bool + /// Lazy: `nil` for non-generic candidates; otherwise a thunk that + /// builds the inner `SpecializationRequest` on demand. Lazy + /// evaluation prevents `Array>`-style infinite descent + /// during `findCandidates`. + public let nestedRequest: (@Sendable () throws -> SpecializationRequest)? +} +``` + +`SpecializationSelection` becomes tree-shaped at the type level (rather than via the `.boundGeneric` enum case). Approach 4 effectively merges Approach 2's selection model with a UX layer that drives the binding from the candidate side. + +### Open questions + +- **`Hashable` requirement.** `nestedRequest` is a closure and cannot participate in hashing — move it to a separate sidecar dictionary keyed by candidate identity, or relax `Hashable`. +- **Cycle safety.** `findCandidates` must not eagerly walk nested requests; the lazy thunk plus an explicit `expandCandidate(_:)` entrypoint preserves the existing O(types) cost. +- **Composability with 2.** If both 2 and 4 ship, `.boundGeneric` and the tree-shape `Candidate` describe overlapping state. Decide whether Approach 4 replaces `.boundGeneric` or layers on top of it (the latter is simpler — Approach 4 becomes pure UI affordance). + +### Risks + +- `Candidate: Hashable` breakage if the closure cannot be excluded cleanly. +- Increased coupling between `Candidate` and `GenericSpecializer` (the closure must capture the specializer or its inputs). + +### Effort + +Medium-large. Touches Models, `findCandidates`, and every consumer of `Candidate` (UI especially). + +--- + +## Out of scope for this roadmap + +- TypePack / Value generic parameters as bound generic arguments. `makeRequest` already rejects these at the source side (`GenericSpecializer.swift:77`); bound-generic recursion inherits the same restriction. +- File-mode (`MachO == MachOFile`) execution. The recursion in Approach 2 routes through `specialize`, which is `MachO == MachOImage`-only. Bound-generic selections are restricted to image mode by construction — same constraint as `.specialized` today. +- Caching of repeated inner specializations across outer calls. Possible future optimization; not required for correctness. diff --git a/Sources/MachOFixtureSupport/DyldSharedCachePath.swift b/Sources/MachOFixtureSupport/DyldSharedCachePath.swift index 841f5053..c35a7663 100644 --- a/Sources/MachOFixtureSupport/DyldSharedCachePath.swift +++ b/Sources/MachOFixtureSupport/DyldSharedCachePath.swift @@ -3,4 +3,5 @@ package enum DyldSharedCachePath: String { case macOS_15_5 = "/Volumes/RE/Dyld-Shared-Cache/macOS/15.5/dyld_shared_cache_arm64e" case iOS_18_5 = "/Volumes/Generic/iOS Systems/22F76__iPhone17,5/dyld_shared_cache_arm64e" case iOS_26_1 = "/Volumes/Generic/iOS Systems/23B85__iPhone17,5/dyld_shared_cache_arm64e" + case issueCase = "/Users/JH/Downloads/19E241__iPhone11,2_4_6_iPhone12,3_5/dyld_shared_cache_arm64e" } diff --git a/Sources/MachOFixtureSupport/MachOFileName.swift b/Sources/MachOFixtureSupport/MachOFileName.swift index 93f48db2..8f6eb545 100644 --- a/Sources/MachOFixtureSupport/MachOFileName.swift +++ b/Sources/MachOFixtureSupport/MachOFileName.swift @@ -22,4 +22,5 @@ package enum MachOFileName: String { case SymbolTests = "../../Tests/Projects/SymbolTests/DerivedData/SymbolTests/Build/Products/Release/SymbolTests.framework/Versions/A/SymbolTests" case SymbolTestsCore = "../../Tests/Projects/SymbolTests/DerivedData/SymbolTests/Build/Products/Release/SymbolTestsCore.framework/Versions/A/SymbolTestsCore" + case issueCase = "/Users/JH/Downloads/SnippetUI/SnippetUI" } diff --git a/Sources/MachOFixtureSupport/MachOImageName.swift b/Sources/MachOFixtureSupport/MachOImageName.swift index 80e31f74..4360674b 100644 --- a/Sources/MachOFixtureSupport/MachOImageName.swift +++ b/Sources/MachOFixtureSupport/MachOImageName.swift @@ -17,7 +17,10 @@ package enum MachOImageName: String { case ScreenSharingKit case DesignLibrary case SFSymbols - + case SnippetUI + case SiriOntology + + case SymbolTests = "../../Tests/Projects/SymbolTests/DerivedData/SymbolTests/Build/Products/Release/SymbolTests.framework/Versions/A/SymbolTests" case SymbolTestsCore = "../../Tests/Projects/SymbolTests/DerivedData/SymbolTests/Build/Products/Release/SymbolTestsCore.framework/Versions/A/SymbolTestsCore" diff --git a/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift b/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift index 9fd82d9c..981fbee2 100644 --- a/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift +++ b/Sources/MachOSwiftSection/Models/Generic/GenericContext.swift @@ -246,12 +246,34 @@ public struct TargetGenericContext Any.Type? { - autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInContext(.init(bitPattern: mangledTypeName.startOffset), .init(mangledTypeName.size), nil, nil)) + autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInContext(.init(bitPattern: mangledTypeName.startOffset), .init(mangledTypeName.size), genericContext, genericArguments)) } public static func getTypeByMangledNameInEnvironment(_ mangledTypeName: MangledName, genericContext: UnsafeRawPointer? = nil, genericArguments: UnsafeRawPointer? = nil) throws -> Any.Type? { - autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInEnvironment(.init(bitPattern: mangledTypeName.startOffset), .init(mangledTypeName.size), nil, nil)) + autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInEnvironment(.init(bitPattern: mangledTypeName.startOffset), .init(mangledTypeName.size), genericContext, genericArguments)) } public static func getTypeByMangledNameInContext(_ mangledTypeName: MangledName, genericContext: UnsafeRawPointer? = nil, genericArguments: UnsafeRawPointer? = nil, in machOImage: MachOImage) throws -> Any.Type? { let pointer = try UnsafePointer(bitPattern: Int(bitPattern: machOImage.ptr) + mangledTypeName.startOffset) - return autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInContext(pointer, .init(mangledTypeName.size), nil, nil)) + return autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInContext(pointer, .init(mangledTypeName.size), genericContext, genericArguments)) } public static func getTypeByMangledNameInEnvironment(_ mangledTypeName: MangledName, genericContext: UnsafeRawPointer? = nil, genericArguments: UnsafeRawPointer? = nil, in machOImage: MachOImage) throws -> Any.Type? { let pointer = try UnsafePointer(bitPattern: Int(bitPattern: machOImage.ptr) + mangledTypeName.startOffset) - return autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInEnvironment(pointer, .init(mangledTypeName.size), nil, nil)) + return autoBitCast(MachOSwiftSectionC.swift_getTypeByMangledNameInEnvironment(pointer, .init(mangledTypeName.size), genericContext, genericArguments)) + } + + /// Resolves a mangled type name interpreted within the generic context of a + /// specialized in-process value-type metadata. + /// + /// The runtime's `swift_getTypeByMangledNameInContext` expects two pieces of + /// information: the enclosing context descriptor and the array of generic + /// argument metadata pointers. Both can be derived from a specialized + /// in-process metadata pointer alone: + /// - descriptor: stored directly inside the metadata header. + /// - generic arguments: laid out immediately after the metadata header + /// (offset = `sizeof(metadata header) / sizeof(StoredPointer)` words, + /// mirroring `TargetStructMetadata::getGenericArgumentOffset()` / + /// `TargetEnumMetadata::getGenericArgumentOffset()` in the Swift runtime). + /// + /// `metadata` must have been constructed in-process (e.g. via + /// `createInProcess`) so that `asPointer` and `layout.descriptor.address` + /// refer to live memory. + public static func getTypeByMangledNameInContext(_ mangledTypeName: MangledName, specializedFrom metadata: M, in machOImage: MachOImage) throws -> Any.Type? { + let metadataPointer = try metadata.asPointer + let descriptorPointer = try UnsafeRawPointer(bitPattern: UInt(metadata.layout.descriptor.address)) + let genericArgumentsPointer = metadataPointer.advanced(by: MemoryLayout.size) + return try getTypeByMangledNameInContext(mangledTypeName, genericContext: descriptorPointer, genericArguments: genericArgumentsPointer, in: machOImage) + } + + /// In-process variant of `getTypeByMangledNameInContext(_:specializedFrom:in:)`. + /// + /// Use this when the mangled name was read from in-process descriptor + /// memory (e.g. via the no-arg `mangledTypeName()` reads of nested + /// field records). `mangledTypeName.startOffset` is interpreted as an + /// absolute in-process pointer rather than a Mach-O file offset. + public static func getTypeByMangledNameInContext(_ mangledTypeName: MangledName, specializedFrom metadata: M) throws -> Any.Type? { + let metadataPointer = try metadata.asPointer + let descriptorPointer = try UnsafeRawPointer(bitPattern: UInt(metadata.layout.descriptor.address)) + let genericArgumentsPointer = metadataPointer.advanced(by: MemoryLayout.size) + return try getTypeByMangledNameInContext(mangledTypeName, genericContext: descriptorPointer, genericArguments: genericArgumentsPointer) + } + + /// Class-specialized variant of `getTypeByMangledNameInContext(_:specializedFrom:in:)`. + /// + /// Class metadata's generic-argument offset is not a constant. Swift's + /// runtime computes it from the descriptor (`TargetClassDescriptor::getGenericArgumentOffset`) + /// — for non-resilient superclasses it derives from the `(metadataNegativeSize, + /// metadataPositiveSize, numImmediateMembers, areImmediateMembersNegative)` + /// quartet; for resilient superclasses it reads the runtime-populated + /// `StoredClassMetadataBounds.immediateMembersOffset` (in bytes) attached + /// to the descriptor. We follow the same branching here so the resulting + /// `genericArguments` pointer lands on the inline argument array regardless + /// of layout. + /// + /// Returns `nil` if the metadata's descriptor pointer is null (pure ObjC + /// class instance — no Swift descriptor to substitute against). + public static func getTypeByMangledNameInContext(_ mangledTypeName: MangledName, specializedFrom metadata: ClassMetadataObjCInterop, in machOImage: MachOImage) throws -> Any.Type? { + guard let descriptor = try metadata.descriptor() else { return nil } + let metadataPointer = try metadata.asPointer + let descriptorPointer = try UnsafeRawPointer(bitPattern: UInt(metadata.layout.descriptor.address)) + let genericArgumentOffsetWords: Int + if descriptor.hasResilientSuperclass { + // The runtime fills `immediateMembersOffset` (bytes) when the + // class metadata is realized; by the time we hold the in-process + // metadata pointer here, it is current. + let bounds = try descriptor.resilientMetadataBounds() + genericArgumentOffsetWords = Int(bounds.layout.immediateMembersOffset) / MemoryLayout.size + } else { + genericArgumentOffsetWords = Int(descriptor.nonResilientImmediateMembersOffset) + } + let genericArgumentsPointer = metadataPointer.advanced(by: genericArgumentOffsetWords * MemoryLayout.size) + return try getTypeByMangledNameInContext(mangledTypeName, genericContext: descriptorPointer, genericArguments: genericArgumentsPointer, in: machOImage) + } + + /// In-process variant of the class-specialized + /// `getTypeByMangledNameInContext(_:specializedFrom:in:)`. See the + /// value-type sibling above for the rationale. + public static func getTypeByMangledNameInContext(_ mangledTypeName: MangledName, specializedFrom metadata: ClassMetadataObjCInterop) throws -> Any.Type? { + guard let descriptor = try metadata.descriptor() else { return nil } + let metadataPointer = try metadata.asPointer + let descriptorPointer = try UnsafeRawPointer(bitPattern: UInt(metadata.layout.descriptor.address)) + let genericArgumentOffsetWords: Int + if descriptor.hasResilientSuperclass { + let bounds = try descriptor.resilientMetadataBounds() + genericArgumentOffsetWords = Int(bounds.layout.immediateMembersOffset) / MemoryLayout.size + } else { + genericArgumentOffsetWords = Int(descriptor.nonResilientImmediateMembersOffset) + } + let genericArgumentsPointer = metadataPointer.advanced(by: genericArgumentOffsetWords * MemoryLayout.size) + return try getTypeByMangledNameInContext(mangledTypeName, genericContext: descriptorPointer, genericArguments: genericArgumentsPointer) } public static func conformsToProtocol(metatype: Any.Type, protocolType: Any.Type) throws -> ProtocolWitnessTable? { diff --git a/Sources/MachOTestingSupport/GenericSpecializationTestingEnvironment.swift b/Sources/MachOTestingSupport/GenericSpecializationTestingEnvironment.swift new file mode 100644 index 00000000..ff11ef7b --- /dev/null +++ b/Sources/MachOTestingSupport/GenericSpecializationTestingEnvironment.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +import MachOKit +@_spi(Internals) import MachOSymbols +import MachOSwiftSection +@_spi(Support) import SwiftInterface + +// MARK: - Process-wide caches + +/// Shared per-process `MachOImage.current()` reference. +/// +/// `MachOImage.current()` is documented to return the same identity on every +/// call, so caching here just spares the function-call overhead and gives +/// callers a stable property to read. +private let _sharedMachO: MachOImage = .current() + +/// One-shot cache of a fully-prepared `SwiftInterfaceIndexer` over the +/// current image plus Foundation and libswiftCore. +/// +/// swift-testing instantiates a fresh suite struct per `@Test`; the actor +/// lets every conforming suite share a single prepared indexer instead of +/// paying the preparation cost N × suite-count times. +private actor SharedSpecializationIndexerCache { + static let shared = SharedSpecializationIndexerCache() + + private var indexerCache: SwiftInterfaceIndexer? + + enum CacheError: Error, LocalizedError { + case missingImage(name: String) + + var errorDescription: String? { + switch self { + case .missingImage(let name): + return "expected MachOImage(name: \"\(name)\") to be loadable for the test fixture" + } + } + } + + func indexer() async throws -> SwiftInterfaceIndexer { + if let indexerCache { return indexerCache } + let indexer = SwiftInterfaceIndexer(in: MachOImage.current()) + try indexer.addSubIndexer(SwiftInterfaceIndexer(in: Self.requireImage(name: "Foundation"))) + try indexer.addSubIndexer(SwiftInterfaceIndexer(in: Self.requireImage(name: "libswiftCore"))) + try await indexer.prepare() + indexerCache = indexer + return indexer + } + + private static func requireImage(name: String) throws -> MachOImage { + guard let image = MachOImage(name: name) else { + throw CacheError.missingImage(name: name) + } + return image + } +} + +// MARK: - Protocol + +/// Shared environment for swift-testing suites that drive end-to-end +/// generic-specialization machinery: each conforming suite gets a sync +/// `machO` and an async `indexer` for free, plus descriptor-lookup helpers, +/// all backed by a single per-process cache. +/// +/// Lives in `MachOTestingSupport` so any test target depending on it can +/// adopt the protocol without copy-pasting the boilerplate or reaching into +/// another test file's nested namespace. +package protocol GenericSpecializationTestingEnvironment { + var machO: MachOImage { get } + var indexer: SwiftInterfaceIndexer { get async throws } +} + +// MARK: - Default implementations + +extension GenericSpecializationTestingEnvironment { + /// Sync access to the cached `MachOImage.current()` — every conforming + /// suite shares the same instance. + package var machO: MachOImage { + _sharedMachO + } + + /// Async access to the prepared indexer. The actor cache builds it once + /// per process and hands every test the same reference, so the awaiter + /// pays the preparation cost zero times after the first hit. + package var indexer: SwiftInterfaceIndexer { + get async throws { + try await SharedSpecializationIndexerCache.shared.indexer() + } + } + + /// Resolves the first struct context descriptor whose name contains + /// `nameContains`. Substring matching mirrors how nested fixtures are + /// referenced in tests; full module-qualified names are not required. + package func structDescriptor(named nameContains: String) throws -> StructDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.struct?.name(in: machO).contains(nameContains) == true + }?.struct, + "expected a struct context descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Resolves a struct descriptor and binds it to the in-process reader via + /// `asPointerWrapper(in:)`. Required for callers that invoke the + /// no-argument overloads of descriptor methods (e.g. `genericContext()`), + /// which read through the descriptor's embedded reader rather than an + /// explicit `MachOImage` argument. + package func inProcessStructDescriptor(named nameContains: String) throws -> StructDescriptor { + try structDescriptor(named: nameContains).asPointerWrapper(in: machO) + } + + /// Resolves the first enum context descriptor whose name contains + /// `nameContains`. Mirrors `structDescriptor(named:)` for enum fixtures. + package func enumDescriptor(named nameContains: String) throws -> EnumDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.enum?.name(in: machO).contains(nameContains) == true + }?.enum, + "expected an enum context descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Resolves the first class context descriptor whose name contains + /// `nameContains`. Mirrors `structDescriptor(named:)` for class fixtures. + package func classDescriptor(named nameContains: String) throws -> ClassDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.class?.name(in: machO).contains(nameContains) == true + }?.class, + "expected a class context descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Resolves the descriptor along with its generic context. Used by tests + /// that inspect the generic header (e.g. `numKeyArguments`) in addition + /// to driving `GenericSpecializer`. + package func genericStructFixture( + named nameContains: String + ) throws -> (descriptor: StructDescriptor, genericContext: GenericContext) { + let descriptor = try structDescriptor(named: nameContains) + let genericContext = try #require( + try descriptor.genericContext(in: machO), + "expected genericContext on \(nameContains)" + ) + return (descriptor, genericContext) + } +} diff --git a/Sources/MachOTestingSupport/SpecializedMangledNameFixtures.swift b/Sources/MachOTestingSupport/SpecializedMangledNameFixtures.swift new file mode 100644 index 00000000..e58f7c8e --- /dev/null +++ b/Sources/MachOTestingSupport/SpecializedMangledNameFixtures.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Shared generic fixtures for tests that exercise the specialized +/// mangled-name resolution helpers in `RuntimeFunctions`. +/// +/// Lives in `MachOTestingSupport` so any test target that links it gets the +/// type context descriptors compiled into the test executable's +/// `__swift5_types` section. Tests look the descriptors up by substring on +/// `MachOImage.current()` and force per-instantiation metadata emission by +/// touching `T.self` before calling `Metadata.createInProcess(...)`. +/// +/// Conventions: +/// - Every fixture is `package` so it stays inside the SPM package. +/// - Names are intentionally specific so substring lookups don't collide +/// with stdlib / Foundation types in the same image. +package enum SpecializedMangledNameFixtures { + // MARK: - Structs + + package struct SingleParameterBox { + package let value: A + + package init(value: A) { self.value = value } + } + + package struct TwoParameterPair { + package let first: A + package let second: B + + package init(first: A, second: B) { + self.first = first + self.second = second + } + } + + package struct GenericArrayWrapper { + package let values: [A] + package let count: Int + + package init(values: [A], count: Int) { + self.values = values + self.count = count + } + } + + package struct OptionalGenericFieldStruct { + package let optional: A? + + package init(optional: A?) { self.optional = optional } + } + + package struct DictionaryGenericFieldStruct { + package let dictionary: [Key: Value] + + package init(dictionary: [Key: Value]) { self.dictionary = dictionary } + } + + package struct NonGenericIntStruct { + package let count: Int + + package init(count: Int) { self.count = count } + } + + /// Hosts a nested generic struct so the expanded-field-offset walker + /// has to recurse with the *nested* struct's specialized metadata as + /// the next-level substitution context. Specifically: a field of type + /// `SingleParameterBox` whose own field `value: A` ultimately + /// resolves through the substitution chain + /// `NestedStructHostingStruct` → `SingleParameterBox` → + /// `value: Int`. + package struct NestedStructHostingStruct { + package let inner: SingleParameterBox + package let trailingCount: Int + + package init(inner: SingleParameterBox, trailingCount: Int) { + self.inner = inner + self.trailingCount = trailingCount + } + } + + /// Two-level struct where the *inner* struct's field is a class — the + /// expanded-field-offset walker must NOT try to recurse into the class + /// metadata as if it were a struct. Pre-fix, the bogus + /// `StructMetadata.createInProcess(classMetatype)` produced a misaligned + /// metadata, and `metadata.structDescriptor()`'s internal force-unwrap + /// (`descriptor().struct!`) crashed on the malformed descriptor. The + /// kind-checked construction path returns `nil` for class metatypes + /// and skips the recursion safely. + package struct StructHostingClassField { + package let reference: GenericContainerClass + package let trailingCount: Int + + package init(reference: GenericContainerClass, trailingCount: Int) { + self.reference = reference + self.trailingCount = trailingCount + } + } + + // MARK: - Enums + + package enum GenericResultEnum { + case success(A) + case failure(E) + } + + package struct FixtureError: Error { + package init() {} + } + + // MARK: - Classes + + /// Plain Swift class with one generic parameter. Non-resilient + /// superclass (root) — exercises the `nonResilientImmediateMembersOffset` + /// branch of the class resolver. + package final class GenericContainerClass { + package let value: A + + package init(value: A) { self.value = value } + } + + /// Two-parameter class. Pins positional ordering for the class path. + package final class TwoParameterContainerClass { + package let first: A + package let second: B + + package init(first: A, second: B) { + self.first = first + self.second = second + } + } + + /// Generic parent for `GenericSubclass`. Non-final on purpose so the + /// subclass below has something to inherit from. + package class GenericParentClass { + package let parentValue: A + + package init(parentValue: A) { self.parentValue = parentValue } + } + + /// Generic subclass of a generic parent. The subclass's field descriptor + /// lists only `childValue: B` — verifies the resolver substitutes against + /// the innermost type's parameter ordering. + package final class GenericSubclass: GenericParentClass { + package let childValue: B + + package init(parentValue: A, childValue: B) { + self.childValue = childValue + super.init(parentValue: parentValue) + } + } +} diff --git a/Sources/SwiftDump/Dumper/ClassDumper.swift b/Sources/SwiftDump/Dumper/ClassDumper.swift index 90ecd974..09147ff5 100644 --- a/Sources/SwiftDump/Dumper/ClassDumper.swift +++ b/Sources/SwiftDump/Dumper/ClassDumper.swift @@ -15,7 +15,7 @@ package struct ClassDumper: Type package let dumped: Dumped - package let metadata: Metadata? + package let metadataContext: DumperMetadataContext? package let configuration: DumperConfiguration @@ -25,12 +25,12 @@ package struct ClassDumper: Type private var symbolIndexStore package init(_ dumped: Dumped, using configuration: DumperConfiguration, in machO: MachO) { - self.init(dumped, metadata: nil, using: configuration, in: machO) + self.init(dumped, metadataContext: nil, using: configuration, in: machO) } - package init(_ dumped: Dumped, metadata: Metadata?, using configuration: DumperConfiguration, in machO: MachO) { + package init(_ dumped: Dumped, metadataContext: DumperMetadataContext?, using configuration: DumperConfiguration, in machO: MachO) { self.dumped = dumped - self.metadata = metadata + self.metadataContext = metadataContext self.configuration = configuration self.machO = machO } @@ -55,7 +55,12 @@ package struct ClassDumper: Type try await name let superclass = try await superclass - if let genericContext = dumped.genericContext { + // For a specialized class dumper, `name` already prints the + // bound generic form so the `` clause would + // duplicate the type-argument display. Emit the superclass + // segment directly in that case. + let isBound = boundDumpedMetatype() != nil + if !isBound, let genericContext = dumped.genericContext { try await genericContext.dumpGenericSignature(resolver: demangleResolver, in: machO) { superclass } @@ -125,14 +130,8 @@ package struct ClassDumper: Type private var fieldOffsets: [Int]? { guard configuration.printFieldOffset else { return nil } - guard let metadataAccessor = try? dumped.descriptor.metadataAccessorFunction(in: machO), !dumped.flags.isGeneric else { return nil } - guard let metadataWrapper = try? metadataAccessor(request: .init()).value.resolve(in: machO) else { return nil } - switch metadataWrapper { - case .class(let metadata): - return try? metadata.fieldOffsets(for: dumped.descriptor, in: machO).map { $0.cast() } - default: - return nil - } + guard let metadataContext else { return nil } + return try? metadataContext.metadata.fieldOffsets(for: dumped.descriptor, in: metadataContext.readingContext).map { $0.cast() } } package var fields: SemanticString { @@ -147,9 +146,8 @@ package struct ClassDumper: Type let endOffset: Int? if let nextFieldOffset = fieldOffsets[safe: offset.index + 1] { endOffset = nextFieldOffset - } else if !dumped.flags.isGeneric, - let machOImage = machO.asMachOImage, - let metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machOImage), + } else if let machOImage = machO.asMachOImage, + let metatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage), let metadata = try? Metadata.createInProcess(metatype), let typeLayout = try? metadata.asMetadataWrapper().valueWitnessTable().typeLayout { endOffset = startOffset + Int(typeLayout.size) @@ -163,13 +161,16 @@ package struct ClassDumper: Type } } - if configuration.printTypeLayout, !dumped.flags.isGeneric, let machO = machO.asMachOImage, let metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO), let metadata = try? Metadata.createInProcess(metatype) { - try await metadata.asMetadataWrapper().dumpTypeLayout(using: configuration) + if configuration.printTypeLayout, + let machOImage = machO.asMachOImage, + let resolvedMetatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage), + let resolvedMetadata = try? Metadata.createInProcess(resolvedMetatype) { + try await resolvedMetadata.asMetadataWrapper().dumpTypeLayout(using: configuration) } Indent(level: configuration.indentation) - let demangledTypeNode = try MetadataReader.demangleType(for: mangledTypeName, in: machO) + let demangledTypeNode = try fieldDemangledTypeNode(for: mangledTypeName) let fieldName = try fieldRecord.fieldName(in: machO) @@ -392,7 +393,11 @@ package struct ClassDumper: Type package var name: SemanticString { get async throws { - try await _name(using: demangleResolver) + if let boundNode = boundDumpedTypeNode() { + try await resolveBoundDumpedTypeName(boundNode) + } else { + try await _name(using: demangleResolver) + } } } diff --git a/Sources/SwiftDump/Dumper/EnumDumper.swift b/Sources/SwiftDump/Dumper/EnumDumper.swift index fb14dc56..88ba2052 100644 --- a/Sources/SwiftDump/Dumper/EnumDumper.swift +++ b/Sources/SwiftDump/Dumper/EnumDumper.swift @@ -16,7 +16,7 @@ package struct EnumDumper: Typed package let dumped: Enum - package let metadata: Metadata? + package let metadataContext: DumperMetadataContext? package let configuration: DumperConfiguration @@ -26,12 +26,12 @@ package struct EnumDumper: Typed private var symbolIndexStore package init(_ dumped: Dumped, using configuration: DumperConfiguration, in machO: MachO) { - self.init(dumped, metadata: nil, using: configuration, in: machO) + self.init(dumped, metadataContext: nil, using: configuration, in: machO) } - package init(_ dumped: Dumped, metadata: Metadata?, using configuration: DumperConfiguration, in machO: MachO) { + package init(_ dumped: Dumped, metadataContext: DumperMetadataContext?, using configuration: DumperConfiguration, in machO: MachO) { self.dumped = dumped - self.metadata = metadata + self.metadataContext = metadataContext self.configuration = configuration self.machO = machO } @@ -48,7 +48,11 @@ package struct EnumDumper: Typed try await name - if let genericContext = dumped.genericContext { + // Skip the generic-signature clause when `name` already + // rendered the bound generic form (see StructDumper for the + // matching reasoning). + let isBound = boundDumpedMetatype() != nil + if !isBound, let genericContext = dumped.genericContext { try await genericContext.dumpGenericSignature(resolver: demangleResolver, in: machO) { if let invertibleProtocolSet = dumped.invertibleProtocolSet, invertibleProtocolSet.hasInvertedProtocols { invertibleProtocolSet.dumpInvertedProtocolsInheritance @@ -120,11 +124,13 @@ package struct EnumDumper: Typed var isTypeLayoutPrinted = false - if !mangledTypeName.isEmpty { - if configuration.printTypeLayout, !dumped.flags.isGeneric, let machO = machO.asMachOImage, let metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO), let metadata = try? Metadata.createInProcess(metatype) { - try await metadata.asMetadataWrapper().dumpTypeLayout(using: configuration) - isTypeLayoutPrinted = true - } + if !mangledTypeName.isEmpty, + configuration.printTypeLayout, + let machOImage = machO.asMachOImage, + let resolvedMetatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage), + let resolvedMetadata = try? Metadata.createInProcess(resolvedMetatype) { + try await resolvedMetadata.asMetadataWrapper().dumpTypeLayout(using: configuration) + isTypeLayoutPrinted = true } if let `case` = enumLayout?.cases[safe: offset.index] { @@ -152,7 +158,7 @@ package struct EnumDumper: Typed try MemberDeclaration("\(fieldRecord.fieldName(in: machO))") if !mangledTypeName.isEmpty { - let node = try MetadataReader.demangleType(for: mangledTypeName, in: machO) + let node = try fieldDemangledTypeNode(for: mangledTypeName) let demangledName = try await demangleResolver.resolve(for: node) if node.firstChild?.isKind(of: .tuple) ?? false { demangledName @@ -214,7 +220,11 @@ package struct EnumDumper: Typed package var name: SemanticString { get async throws { - try await _name(using: demangleResolver) + if let boundNode = boundDumpedTypeNode() { + try await resolveBoundDumpedTypeName(boundNode) + } else { + try await _name(using: demangleResolver) + } } } diff --git a/Sources/SwiftDump/Dumper/StructDumper.swift b/Sources/SwiftDump/Dumper/StructDumper.swift index 54220f45..b672f2ea 100644 --- a/Sources/SwiftDump/Dumper/StructDumper.swift +++ b/Sources/SwiftDump/Dumper/StructDumper.swift @@ -15,7 +15,7 @@ package struct StructDumper: Typ package let dumped: Struct - package let metadata: StructMetadata? + package let metadataContext: DumperMetadataContext? package let configuration: DumperConfiguration @@ -25,12 +25,12 @@ package struct StructDumper: Typ private var symbolIndexStore package init(_ dumped: Dumped, using configuration: DumperConfiguration, in machO: MachO) { - self.init(dumped, metadata: nil, using: configuration, in: machO) + self.init(dumped, metadataContext: nil, using: configuration, in: machO) } - package init(_ dumped: Dumped, metadata: Metadata?, using configuration: DumperConfiguration, in machO: MachO) { + package init(_ dumped: Dumped, metadataContext: DumperMetadataContext?, using configuration: DumperConfiguration, in machO: MachO) { self.dumped = dumped - self.metadata = metadata + self.metadataContext = metadataContext self.configuration = configuration self.machO = machO } @@ -47,7 +47,14 @@ package struct StructDumper: Typ try await name - if let genericContext = dumped.genericContext { + // When the dumper is rendering a specialized type (`Foo`), + // the bound name printed by `name` already carries the concrete + // type arguments; emitting the generic-signature clause again + // would produce `Foo`. Skip the clause in + // that case and only keep the invertible-protocol marker, which + // is orthogonal to substitution. + let isBound = boundDumpedMetatype() != nil + if !isBound, let genericContext = dumped.genericContext { try await genericContext.dumpGenericSignature(resolver: demangleResolver, in: machO) { if let invertibleProtocolSet = dumped.invertibleProtocolSet, invertibleProtocolSet.hasInvertedProtocols { invertibleProtocolSet.dumpInvertedProtocolsInheritance @@ -61,7 +68,8 @@ package struct StructDumper: Typ private var fieldOffsets: [Int]? { guard configuration.printFieldOffset else { return nil } - return try? metadata?.fieldOffsets(for: dumped.descriptor, in: machO).map { $0.cast() } + guard let metadataContext else { return nil } + return try? metadataContext.metadata.fieldOffsets(for: dumped.descriptor, in: metadataContext.readingContext).map { $0.cast() } } package var fields: SemanticString { @@ -76,9 +84,8 @@ package struct StructDumper: Typ let endOffset: Int? if let nextFieldOffset = fieldOffsets[safe: offset.index + 1] { endOffset = nextFieldOffset - } else if !dumped.flags.isGeneric, - let machOImage = machO.asMachOImage, - let metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machOImage), + } else if let machOImage = machO.asMachOImage, + let metatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage), let metadata = try? Metadata.createInProcess(metatype), let typeLayout = try? metadata.asMetadataWrapper().valueWitnessTable().typeLayout { endOffset = startOffset + Int(typeLayout.size) @@ -92,13 +99,16 @@ package struct StructDumper: Typ } } - if configuration.printTypeLayout, !dumped.flags.isGeneric, let machO = machO.asMachOImage, let metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO), let metadata = try? Metadata.createInProcess(metatype) { - try await metadata.asMetadataWrapper().dumpTypeLayout(using: configuration) + if configuration.printTypeLayout, + let machOImage = machO.asMachOImage, + let resolvedMetatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage), + let resolvedMetadata = try? Metadata.createInProcess(resolvedMetatype) { + try await resolvedMetadata.asMetadataWrapper().dumpTypeLayout(using: configuration) } Indent(level: configuration.indentation) - let demangledTypeNode = try MetadataReader.demangleType(for: mangledTypeName, in: machO) + let demangledTypeNode = try fieldDemangledTypeNode(for: mangledTypeName) let fieldName = try fieldRecord.fieldName(in: machO) @@ -167,7 +177,19 @@ package struct StructDumper: Typ package var name: SemanticString { get async throws { - try await _name(using: demangleResolver) + // For a specialized dumper, prefer the bound generic node + // (e.g. `Foo`) so the rendered declaration carries the + // concrete type arguments. `resolveBoundDumpedTypeName` keeps + // the outer head as a `.declaration` while leaving the type + // arguments inside `<...>` with regular `.name` styling — the + // same semantics every other type reference in the dump uses. + // The interface-form name used for symbol-index lookups stays + // on the unbound path below. + if let boundNode = boundDumpedTypeNode() { + try await resolveBoundDumpedTypeName(boundNode) + } else { + try await _name(using: demangleResolver) + } } } diff --git a/Sources/SwiftDump/Extensions/TypeWrapper+Dumper.swift b/Sources/SwiftDump/Extensions/TypeWrapper+Dumper.swift index 90d0e4f0..16294b8e 100644 --- a/Sources/SwiftDump/Extensions/TypeWrapper+Dumper.swift +++ b/Sources/SwiftDump/Extensions/TypeWrapper+Dumper.swift @@ -2,42 +2,42 @@ import Foundation import MachOSwiftSection extension TypeContextWrapper { - package func dumper(using configuration: DumperConfiguration, genericParamSpecializations: (metadata: [Metadata], protocolWitnessTables: [ProtocolWitnessTable])? = nil, in machO: some MachOSwiftSectionRepresentableWithCache) -> any TypedDumper { + package func dumper(using configuration: DumperConfiguration, metadata: MetadataWrapper? = nil, in machO: some MachOSwiftSectionRepresentableWithCache) -> any TypedDumper { switch self { case .enum(let type): - let metadata: EnumMetadata? = if type.descriptor.isGeneric { - if let genericParamSpecializations { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init(), metadatas: genericParamSpecializations.metadata, witnessTables: genericParamSpecializations.protocolWitnessTables).value.resolve(in: machO).enum - } else { - nil - } + let metadataContext: DumperMetadataContext? + // Both `enum` and `optional` wrappers carry an `EnumMetadata` + // payload — the runtime distinguishes them by kind only, but + // the descriptor-level dumper needs the underlying struct + // either way. + if let resolvedMetadata = metadata?.enum ?? metadata?.optional { + metadataContext = .init(metadata: resolvedMetadata, readingContext: type.descriptor.isGeneric ? InProcessContext.shared : MachOContext(machO)) + } else if type.descriptor.isGeneric { + metadataContext = nil } else { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).enum + metadataContext = try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).enum.map { .init(metadata: $0, readingContext: MachOContext(machO)) } } - - return EnumDumper(type, metadata: metadata, using: configuration, in: machO) + return EnumDumper(type, metadataContext: metadataContext, using: configuration, in: machO) case .struct(let type): - let metadata: StructMetadata? = if type.descriptor.isGeneric { - if let genericParamSpecializations { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init(), metadatas: genericParamSpecializations.metadata, witnessTables: genericParamSpecializations.protocolWitnessTables).value.resolve(in: machO).struct - } else { - nil - } + let metadataContext: DumperMetadataContext? + if let metadata = metadata?.struct { + metadataContext = .init(metadata: metadata, readingContext: type.descriptor.isGeneric ? InProcessContext.shared : MachOContext(machO)) + } else if type.descriptor.isGeneric { + metadataContext = nil } else { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).struct + metadataContext = try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).struct.map { .init(metadata: $0, readingContext: MachOContext(machO)) } } - return StructDumper(type, metadata: metadata, using: configuration, in: machO) + return StructDumper(type, metadataContext: metadataContext, using: configuration, in: machO) case .class(let type): - let metadata: ClassMetadataObjCInterop? = if type.descriptor.isGeneric { - if let genericParamSpecializations { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init(), metadatas: genericParamSpecializations.metadata, witnessTables: genericParamSpecializations.protocolWitnessTables).value.resolve(in: machO).class - } else { - nil - } + let metadataContext: DumperMetadataContext? + if let metadata = metadata?.class { + metadataContext = .init(metadata: metadata, readingContext: type.descriptor.isGeneric ? InProcessContext.shared : MachOContext(machO)) + } else if type.descriptor.isGeneric { + metadataContext = nil } else { - try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).class + metadataContext = try? type.descriptor.metadataAccessorFunction(in: machO)?(request: .init()).value.resolve(in: machO).class.map { .init(metadata: $0, readingContext: MachOContext(machO)) } } - return ClassDumper(type, metadata: metadata, using: configuration, in: machO) + return ClassDumper(type, metadataContext: metadataContext, using: configuration, in: machO) } } } diff --git a/Sources/SwiftDump/Protocols/TypedDumper.swift b/Sources/SwiftDump/Protocols/TypedDumper.swift index bdc71262..d4c32c93 100644 --- a/Sources/SwiftDump/Protocols/TypedDumper.swift +++ b/Sources/SwiftDump/Protocols/TypedDumper.swift @@ -6,9 +6,44 @@ import Demangling package protocol TypedDumper: NamedDumper where Dumped: TopLevelType, Dumped.Descriptor: TypeContextDescriptorProtocol { associatedtype Metadata: MetadataProtocol + + var metadataContext: DumperMetadataContext? { get } + @SemanticStringBuilder var fields: SemanticString { get async throws } - init(_ dumped: Dumped, metadata: Metadata?, using configuration: DumperConfiguration, in machO: MachO) + init(_ dumped: Dumped, metadataContext: DumperMetadataContext?, using configuration: DumperConfiguration, in machO: MachO) + + /// Resolves a field's mangled type name to a concrete `Any.Type` using + /// the dumper's specialized metadata, when applicable. + /// + /// This lives as a protocol requirement (rather than only as a + /// constrained extension) so the dispatch is dynamic — `fieldDemangled\ + /// TypeNode(for:)` calls it through `Self` and the constrained variants + /// in the extensions below win for the matching `Metadata` type. The + /// default implementation returns `nil` so dumpers without a matching + /// constraint just skip substitution. + func resolveFieldMetatype(for mangledTypeName: MangledName, in machOImage: MachOImage) -> Any.Type? + + /// Returns the in-process `Any.Type` of the *dumped* type when the + /// dumper is operating on a specialized in-process metadata. Used to + /// drive `boundDumpedTypeNode()`, which substitutes the dumped type's + /// own generic parameters in `name` / `declaration` rendering. + /// + /// Default returns nil; the constrained extensions return the specialized + /// metatype for value- and class-metadata dumpers. + func boundDumpedMetatype() -> Any.Type? +} + +// Default implementations: no substitution. Specialized types pick up the +// constrained variants further below. +extension TypedDumper { + package func resolveFieldMetatype(for mangledTypeName: MangledName, in machOImage: MachOImage) -> Any.Type? { + nil + } + + package func boundDumpedMetatype() -> Any.Type? { + nil + } } extension TypedDumper { @@ -74,18 +109,298 @@ extension TypedDumper { } } +// MARK: - Field metatype resolution + +extension TypedDumper where Metadata: ValueMetadataProtocol { + /// Constrained override of the protocol requirement: resolves a field's + /// mangled type name to its concrete `Any.Type` using a specialized + /// in-process value-type metadata when present. + /// + /// For non-generic top-level types the bare runtime entry is enough. + /// For generic top-level types the field's mangled name can reference + /// the enclosing generic parameters (e.g. depth/index pairs); we + /// substitute via the specialized in-process metadata when the dumper + /// has one. Returns `nil` when the runtime cannot resolve the name — + /// e.g. a generic field on a generic type with no specialized metadata + /// context to substitute against. + package func resolveFieldMetatype(for mangledTypeName: MangledName, in machOImage: MachOImage) -> Any.Type? { + if !dumped.flags.isGeneric { + return try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machOImage) + } + guard let specializedMetadata = metadataContext?.metadata else { return nil } + return try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, specializedFrom: specializedMetadata, in: machOImage) + } + + package func boundDumpedMetatype() -> Any.Type? { + guard dumped.flags.isGeneric, let metadata = metadataContext?.metadata else { return nil } + guard let metadataPointer = try? metadata.asPointer else { return nil } + // Specialized in-process metadata pointers and `Any.Type` are + // representationally identical — bitcasting recovers the metatype + // we'd get from `Foo.self`. + return unsafeBitCast(metadataPointer, to: Any.Type.self) + } +} + +extension TypedDumper where Metadata == ClassMetadataObjCInterop { + /// Class-metadata variant of the protocol requirement. Mirrors the + /// value-type version but routes through the class-specialized + /// runtime overload, which handles the resilient/non-resilient + /// generic-argument-offset branching internally. + package func resolveFieldMetatype(for mangledTypeName: MangledName, in machOImage: MachOImage) -> Any.Type? { + if !dumped.flags.isGeneric { + return try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machOImage) + } + guard let specializedMetadata = metadataContext?.metadata else { return nil } + return try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, specializedFrom: specializedMetadata, in: machOImage) + } + + package func boundDumpedMetatype() -> Any.Type? { + guard dumped.flags.isGeneric, let metadata = metadataContext?.metadata else { return nil } + guard let metadataPointer = try? metadata.asPointer else { return nil } + return unsafeBitCast(metadataPointer, to: Any.Type.self) + } +} + +// MARK: - Field demangled-type-node resolution + +extension TypedDumper { + /// Returns a demangled `Node` describing a field's type, with generic + /// parameters substituted by their concrete arguments when the dumper + /// is operating on a specialized in-process metadata. + /// + /// Strategy: + /// - Non-generic dumps fall through to `MetadataReader.demangleType` + /// against the binary's raw bytes (the existing path; the result + /// contains no generic-param references). + /// - Generic dumps with a `metadataContext` use the resolved + /// specialized `Any.Type`, fetch its own mangled name via Swift's + /// `_mangledTypeName` SPI, and demangle that string. The resulting + /// node tree mentions the substituted concrete types instead of + /// `dependentGenericParamType` placeholders. + /// - Generic dumps without a `metadataContext` fall back to the raw + /// bytes (we have nothing to substitute against), which keeps the + /// unbound representation. + package func fieldDemangledTypeNode(for mangledTypeName: MangledName) throws -> Node { + if let substituted = substitutedFieldNode(for: mangledTypeName) { + return substituted + } + return try MetadataReader.demangleType(for: mangledTypeName, in: machO) + } + + /// Splits the SwiftStdlib 5.3 availability gate (required for + /// `_mangledTypeName`) out of the main control flow. Returns `nil` when + /// the dumper isn't operating on a specialized metadata, when the + /// runtime resolver fails, when the host runtime predates the + /// `_mangledTypeName` SPI, or when `demangleAsNode` cannot parse the + /// resulting string. + private func substitutedFieldNode(for mangledTypeName: MangledName) -> Node? { + guard dumped.flags.isGeneric, + let machOImage = machO.asMachOImage, + let resolvedMetatype = resolveFieldMetatype(for: mangledTypeName, in: machOImage) + else { + return nil + } + return demangledNode(forMetatype: resolvedMetatype) + } + + /// Returns a demangled `Node` for the *dumped* type itself, with its + /// generic parameters bound to the concrete arguments that came from + /// the specialized in-process metadata. Returns `nil` when the dumper + /// isn't operating on a specialized metadata (so callers can fall back + /// to the existing unbound name path). + package func boundDumpedTypeNode() -> Node? { + guard let metatype = boundDumpedMetatype() else { return nil } + return demangledNode(forMetatype: metatype) + } + + /// Shared wrapper around `_mangledTypeName` + `demangleAsNode` so the + /// SwiftStdlib-availability + nil-handling lives in exactly one spot + /// for both field-type and dumped-type substitution. + private func demangledNode(forMetatype metatype: Any.Type) -> Node? { + // `_mangledTypeName` is `SwiftStdlib 5.3` — translates to macOS 11 / + // iOS 14 / tvOS 14 / watchOS 7. Fall back to nil on older runtimes + // so callers stay on the unbound representation. + guard #available(macOS 11, iOS 14, tvOS 14, watchOS 7, *) else { return nil } + guard let resolvedMangledString = _mangledTypeName(metatype) else { return nil } + return try? demangleAsNode(resolvedMangledString, isType: true) + } + + /// Render the bound generic dumped name so that only the *outer* unbound + /// type carries declaration styling. Type arguments inside `<...>` keep + /// regular `.name` styling, matching how every other type reference + /// (e.g. field types) is rendered. + /// + /// Why bother: `replacingTypeNameOrOtherToTypeDeclaration()` is a + /// blanket walk — applied to a whole bound generic node it converts + /// every nested `.type(_, .name)` into `.type(_, .declaration)`, + /// including modules, separators, and the inner type names themselves. + /// For `SwiftUI.HStack` + /// that means the inner `Label` (and its module path) end up tagged as + /// declarations, which is wrong: only the outer `HStack` is the + /// declaration. Splitting the rendering keeps the inner argument tree + /// semantically identical to a regular type reference printed + /// elsewhere in the dump output. + /// + /// Expected `boundNode` shape (produced by + /// `demangleAsNode(_mangledTypeName(specializedMetatype), isType: true)`): + /// ``` + /// Type + /// └── BoundGenericStructure | BoundGenericClass | BoundGenericEnum + /// ├── Type ← unbound name + /// └── TypeList + /// └── Type, … ← type arguments + /// ``` + @SemanticStringBuilder + package func resolveBoundDumpedTypeName(_ boundNode: Node) async throws -> SemanticString { + let resolver = configuration.demangleResolver + if let boundGenericNode = boundNode.firstChild, + boundGenericNode.kind == .boundGenericStructure + || boundGenericNode.kind == .boundGenericClass + || boundGenericNode.kind == .boundGenericEnum, + boundGenericNode.children.count >= 2 { + let unboundType = boundGenericNode.children[0] + let typeList = boundGenericNode.children[1] + // Outer unbound name → declaration styling, mirroring the + // unbound `_name` code path. + try await resolver.resolve(for: unboundType).replacingTypeNameOrOtherToTypeDeclaration() + Standard("<") + for (argumentIndex, argumentType) in typeList.children.enumerated() { + if argumentIndex > 0 { + Standard(", ") + } + // Inner argument → regular `.name` styling, same as field + // type references rendered via the demangle resolver. + try await resolver.resolve(for: argumentType) + } + Standard(">") + } else { + // Unexpected shape (sugar already collapsed at the top, etc.). + // Fall back to whole-tree replacement so the head still picks + // up declaration styling; the inner pieces lose their + // granularity but it's a graceful degradation rather than a + // hard failure. + try await resolver.resolve(for: boundNode).replacingTypeNameOrOtherToTypeDeclaration() + } + } +} + extension TypedDumper { + // MARK: - Previous (pre-substitution) implementation, kept here for + // reference while reading the new walker. Deleting it does not + // change behavior — the new `expandedFieldOffsets(...)` plus + // `walkNestedExpandedFieldOffsets(of:...)` cover every case + // this one did and additionally handle specialized generics. + // + // ```swift + // extension TypedDumper { + // @SemanticStringBuilder + // func expandedFieldOffsets(for mangledTypeName: MangledName, baseOffset: Int, baseIndentation: Int, ancestors: [Bool], in machO: MachOImage?) -> SemanticString { + // let metatype: Any.Type? + // if let machO { + // metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO) + // } else { + // metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName) + // } + // if let metatype, + // let metadata = try? Metadata.createInProcess(metatype).asMetadataWrapper().struct, + // let descriptor = try? metadata.descriptor().struct, !descriptor.isGeneric, + // let nestedFieldOffsets = try? metadata.fieldOffsets(for: descriptor), + // let nestedFieldRecords = try? descriptor.fieldDescriptor().records() { + // let fieldEntries = Array(zip(nestedFieldRecords, nestedFieldOffsets)) + // for (fieldIndex, (nestedFieldRecord, nestedRelativeOffset)) in fieldEntries.enumerated() { + // if let fieldName = try? nestedFieldRecord.fieldName() { + // let absoluteOffset = baseOffset + Int(nestedRelativeOffset) + // let isLastField = fieldIndex == fieldEntries.count - 1 + // let nestedMangledTypeName = try? nestedFieldRecord.mangledTypeName() + // let typeName = nestedMangledTypeName.flatMap { try? MetadataReader.demangleType(for: $0).printSemantic(using: .default).string } ?? "" + // configuration.expandedFieldOffsetComment(fieldName: fieldName, typeName: typeName, offset: absoluteOffset, baseIndentation: baseIndentation, ancestors: ancestors, isLast: isLastField) + // + // if let nestedMangledTypeName { + // expandedFieldOffsets(for: nestedMangledTypeName, baseOffset: absoluteOffset, baseIndentation: baseIndentation, ancestors: ancestors + [isLastField], in: nil) + // } + // } + // } + // } + // } + // } + // ``` + // + // Diff against the new implementation, point-by-point: + // + // 1. Resolution at the *top* hop ignored `metadataContext` — + // `getTypeByMangledNameInContext(mangledTypeName, in: machO)` cannot + // substitute generic parameters. For a specialized + // `Box` dumper, a field `let inner: SingleParameterBox` + // mangled name still references `A`; resolution returned nil and + // the whole expansion was skipped. The new entry calls + // `resolveFieldMetatype(for:in:)` first so the dumper's specialized + // metadata performs the substitution. + // + // 2. The `!descriptor.isGeneric` guard rejected *every* generic + // descriptor — including the legitimately specialized + // `SingleParameterBox` we now want to recurse into. The new + // walker drops that guard because the metadata it holds is already + // a specialized in-process metadata, so `fieldOffsets(for:)` and + // friends are well-defined. + // + // 3. The recursive call passed `in: nil` (in-process resolution) but + // again with no substitution context. Nested fields whose mangled + // names referenced *their parent's* generic parameters could not + // resolve. The new `walkNestedExpandedFieldOffsets(of:...)` threads + // the just-resolved struct metadata as substitution context for + // the next hop, mirroring how Swift's runtime walks + // generic-arguments arrays through nested specializations. + // + // 4. `Metadata.createInProcess(metatype).asMetadataWrapper().struct` + // acted as a kind check: it returned nil for non-struct metatypes + // (class / enum / builtin / function …) so recursion never + // entered them. The new walker accomplishes the same thing + // through `structMetadata(forMetatype:)`. An earlier draft of + // this refactor *lost* that guard by replacing the chain with a + // bare `StructMetadata.createInProcess(metatype)` — the latter + // reads 16 bytes of the metadata blindly, so a class metatype + // produced a misaligned `StructMetadata` whose + // `structDescriptor()` then trapped on its internal + // `descriptor().struct!` force-unwrap. `try?` does not catch a + // forced-unwrap trap, hence the visible `Fatal error: Unexpectedly + // found nil` you'd see when dumping a struct with a class field. + + /// Top-level entry: walks the dumper-owned struct's nested struct + /// fields, emitting `expandedFieldOffsetComment` lines. + /// + /// Substitution flows in two phases: + /// - Top hop: the field's mangled name comes from the dumper's + /// descriptor; specialization (if any) lives in `metadataContext`. + /// Use `resolveFieldMetatype` so generic field types like `let x: A` + /// resolve to the bound concrete type. + /// - Recursive hops: the just-resolved nested struct's metadata is + /// itself a specialized in-process metadata, and any further nested + /// mangled names that mention *its* generic parameters need to + /// substitute against it. `walkNestedExpandedFieldOffsets(of:...)` + /// threads that context through the recursion. @SemanticStringBuilder func expandedFieldOffsets(for mangledTypeName: MangledName, baseOffset: Int, baseIndentation: Int, ancestors: [Bool], in machO: MachOImage?) -> SemanticString { - let metatype: Any.Type? + let topMetatype: Any.Type? if let machO { - metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO) + topMetatype = resolveFieldMetatype(for: mangledTypeName, in: machO) + ?? (try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, in: machO)) } else { - metatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName) + topMetatype = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName) + } + if let topMetatype, + let topStructMetadata = structMetadata(forMetatype: topMetatype) { + walkNestedExpandedFieldOffsets(of: topStructMetadata, baseOffset: baseOffset, baseIndentation: baseIndentation, ancestors: ancestors) } - if let metatype, - let metadata = try? Metadata.createInProcess(metatype).asMetadataWrapper().struct, - let descriptor = try? metadata.descriptor().struct, !descriptor.isGeneric, + } + + /// Recursive walk over a nested struct's fields. Every mangled name + /// read here came from in-process descriptor memory, so substitution + /// uses the no-`MachOImage` overload of `getTypeByMangledNameInContext` + /// (the one that treats `mangledTypeName.startOffset` as an absolute + /// in-process pointer). + @SemanticStringBuilder + private func walkNestedExpandedFieldOffsets(of metadata: StructMetadata, baseOffset: Int, baseIndentation: Int, ancestors: [Bool]) -> SemanticString { + if let descriptor = try? metadata.structDescriptor(), let nestedFieldOffsets = try? metadata.fieldOffsets(for: descriptor), let nestedFieldRecords = try? descriptor.fieldDescriptor().records() { let fieldEntries = Array(zip(nestedFieldRecords, nestedFieldOffsets)) @@ -94,14 +409,56 @@ extension TypedDumper { let absoluteOffset = baseOffset + Int(nestedRelativeOffset) let isLastField = fieldIndex == fieldEntries.count - 1 let nestedMangledTypeName = try? nestedFieldRecord.mangledTypeName() - let typeName = nestedMangledTypeName.flatMap { try? MetadataReader.demangleType(for: $0).printSemantic(using: .default).string } ?? "" + let typeName = nestedTypeName(for: nestedMangledTypeName, parentMetadata: metadata) configuration.expandedFieldOffsetComment(fieldName: fieldName, typeName: typeName, offset: absoluteOffset, baseIndentation: baseIndentation, ancestors: ancestors, isLast: isLastField) - if let nestedMangledTypeName { - expandedFieldOffsets(for: nestedMangledTypeName, baseOffset: absoluteOffset, baseIndentation: baseIndentation, ancestors: ancestors + [isLastField], in: nil) + if let nestedMangledTypeName, + let resolvedMetatype = resolveNestedMetatype(for: nestedMangledTypeName, parentMetadata: metadata), + let nestedStructMetadata = structMetadata(forMetatype: resolvedMetatype) { + walkNestedExpandedFieldOffsets(of: nestedStructMetadata, baseOffset: absoluteOffset, baseIndentation: baseIndentation, ancestors: ancestors + [isLastField]) } } } } } + + /// Returns a `StructMetadata` only when the in-process metadata for + /// `metatype` actually has struct kind. Filtering on + /// `MetadataWrapper.struct` is critical because callers later reach + /// `metadata.structDescriptor()` whose internal `descriptor().struct!` + /// force-unwraps — handing it a misinterpreted class / enum / builtin + /// metadata would crash. Returning `nil` here cleanly skips the + /// recursion for non-struct field types. + private func structMetadata(forMetatype metatype: Any.Type) -> StructMetadata? { + guard let wrapper = try? Metadata.createInProcess(metatype).asMetadataWrapper() else { + return nil + } + return wrapper.struct + } + + /// Resolves a nested field's mangled name to its concrete `Any.Type`, + /// substituting generic parameters via the parent struct's specialized + /// metadata. Falls back to the bare resolver for fully-resolved names. + private func resolveNestedMetatype(for mangledTypeName: MangledName, parentMetadata: StructMetadata) -> Any.Type? { + if let substituted = try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName, specializedFrom: parentMetadata) { + return substituted + } + return try? RuntimeFunctions.getTypeByMangledNameInContext(mangledTypeName) + } + + /// Renders the human-readable type name used in the + /// `expandedFieldOffsetComment` line. When substitution succeeds we + /// print the bound type via `_mangledTypeName` round-trip; otherwise + /// we fall through to the unbound demangling, which keeps the legacy + /// behavior for non-generic / unresolvable names. + private func nestedTypeName(for mangledTypeName: MangledName?, parentMetadata: StructMetadata) -> String { + guard let mangledTypeName else { return "" } + if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, *), + let resolvedMetatype = resolveNestedMetatype(for: mangledTypeName, parentMetadata: parentMetadata), + let mangledString = _mangledTypeName(resolvedMetatype), + let node = try? demangleAsNode(mangledString, isType: true) { + return node.printSemantic(using: .default).string + } + return (try? MetadataReader.demangleType(for: mangledTypeName).printSemantic(using: .default).string) ?? "" + } } diff --git a/Sources/SwiftDump/DemangleResolver.swift b/Sources/SwiftDump/Utils/DemangleResolver.swift similarity index 100% rename from Sources/SwiftDump/DemangleResolver.swift rename to Sources/SwiftDump/Utils/DemangleResolver.swift diff --git a/Sources/SwiftDump/DumperConfiguration.swift b/Sources/SwiftDump/Utils/DumperConfiguration.swift similarity index 100% rename from Sources/SwiftDump/DumperConfiguration.swift rename to Sources/SwiftDump/Utils/DumperConfiguration.swift diff --git a/Sources/SwiftDump/Utils/DumperMetadataContext.swift b/Sources/SwiftDump/Utils/DumperMetadataContext.swift new file mode 100644 index 00000000..e8c0b5ff --- /dev/null +++ b/Sources/SwiftDump/Utils/DumperMetadataContext.swift @@ -0,0 +1,6 @@ +import MachOSwiftSection + +package struct DumperMetadataContext { + package let metadata: Metadata + package let readingContext: any ReadingContext +} diff --git a/Sources/SwiftDump/Dumper/ParentClassVTableCache.swift b/Sources/SwiftDump/Utils/ParentClassVTableCache.swift similarity index 100% rename from Sources/SwiftDump/Dumper/ParentClassVTableCache.swift rename to Sources/SwiftDump/Utils/ParentClassVTableCache.swift diff --git a/Sources/SwiftDump/SwiftAttribute.swift b/Sources/SwiftDump/Utils/SwiftAttribute.swift similarity index 100% rename from Sources/SwiftDump/SwiftAttribute.swift rename to Sources/SwiftDump/Utils/SwiftAttribute.swift diff --git a/Sources/SwiftInterface/Components/Definitions/TypeDefinition.swift b/Sources/SwiftInterface/Components/Definitions/TypeDefinition.swift index 9616ebd0..c4dc1cae 100644 --- a/Sources/SwiftInterface/Components/Definitions/TypeDefinition.swift +++ b/Sources/SwiftInterface/Components/Definitions/TypeDefinition.swift @@ -1,5 +1,6 @@ import Foundation import MachOSwiftSection +import MachOKit import MemberwiseInit import OrderedCollections import SwiftDump @@ -19,8 +20,21 @@ public final class TypeDefinition: Definition { public let type: TypeContextWrapper + /// Injected at construction time. Ordinary indexing-derived definitions + /// receive the unbound form computed from `type.typeName(in:)`; + /// `specialize(with:in:)` derives a bound form via + /// `boundGenericTypeName(...)` (`Box` → `Box`) and feeds it to + /// the designated init, so the property is always immutable post-init. public let typeName: TypeName + /// `true` when this definition was produced via `specialize(with:in:)` — + /// i.e. it carries a runtime-resolved `metadata` and a bound-generic + /// `typeName`. `false` for the canonical, unspecialized definitions + /// produced from a MachO image's section data. Always known at + /// construction time, so callers can branch on the type kind without + /// inspecting the optional `metadata` field. + public let isSpecialized: Bool + public internal(set) weak var parent: TypeDefinition? public internal(set) var typeChildren: [TypeDefinition] = [] @@ -83,15 +97,234 @@ public final class TypeDefinition: Definition { public private(set) var isIndexed: Bool = false + /// Specialized metadata bound to this definition. + /// + /// `nil` for the canonical, unspecialized definition produced from a + /// MachO image's section data. Non-nil only when the definition was + /// produced via `specialize(with:in:)` — in that case the dumper + /// receives this metadata directly and uses it for field offsets, + /// type/enum layout, and value witness queries instead of trying to + /// call the descriptor's metadata accessor. + public internal(set) var metadata: MetadataWrapper? = nil + + /// Specialized children produced from this generic definition via + /// `specialize(with:in:)`. Each entry is a sibling-shaped + /// `TypeDefinition` that wraps the same `type` but carries a + /// runtime-resolved metadata. Lives on the model rather than on the + /// indexer so the indexer remains agnostic of user-driven + /// specialization state. + public private(set) var specializedChildren: [TypeDefinition] = [] + public var hasMembers: Bool { !fields.isEmpty || !variables.isEmpty || !functions.isEmpty || !subscripts.isEmpty || !staticVariables.isEmpty || !staticFunctions.isEmpty || !staticSubscripts.isEmpty || !allocators.isEmpty || !constructors.isEmpty || hasDeallocator } - public init(type: TypeContextWrapper, in machO: MachO) async throws { + /// Designated initializer. Internal so the canonical "derive typeName + /// from `type.typeName(in:)`" path used by indexing cannot be bypassed + /// from outside the package; `specialize(with:in:)` is the only + /// in-package caller that injects a different `typeName`/`isSpecialized` + /// pair. + internal init(type: TypeContextWrapper, typeName: TypeName, isSpecialized: Bool) { self.type = type - let typeName = try type.typeName(in: machO) self.typeName = typeName + self.isSpecialized = isSpecialized + } + + public convenience init(type: TypeContextWrapper, in machO: MachO) async throws { + let typeName = try type.typeName(in: machO) + self.init(type: type, typeName: typeName, isSpecialized: false) + } + + /// Append a new specialized `TypeDefinition` derived from this + /// definition's `type` and the metadata carried by + /// `specializationResult`. + /// + /// Validation, all of which throws `SpecializationError` on failure: + /// 1. The receiver's descriptor must be generic — specializing a + /// non-generic type does not make sense. + /// 2. The `MetadataWrapper`'s case must be compatible with the + /// receiver's `type` case (struct↔struct, class↔class, + /// enum↔enum/optional). A mismatch typically means a + /// `SpecializationResult` produced for a different generic type + /// was handed in. + /// 3. The metadata's resolved descriptor must be the same descriptor + /// as the receiver's `type`. This is the strongest guarantee that + /// the result was produced by specializing exactly this type. + /// + /// The two `machO` parameters serve different roles: + /// - `machO` is used to construct the inner `TypeDefinition` and + /// re-derive its type name. It can be any reader (file or image). + /// - `machOImage` is required because the result's metadata pointer + /// resolves through process memory only (the runtime's metadata + /// cache lives outside any MachO image); descriptor identity + /// validation needs the receiver's descriptor in its in-process + /// form, and that is what `asPointerWrapper(in:)` produces. + @discardableResult + public func specialize( + with specializationResult: SpecializationResult, + typeArgumentNodes: [Node]? = nil, + in machO: MachOImage, + ) async throws -> TypeDefinition { + let metadata = try specializationResult.resolveMetadata() + + try validateSpecialization(metadata: metadata, in: machO) + + // Compute the final typeName up-front so it can flow through the + // designated init: either the unbound form (`Box`) when no type + // arguments are supplied, or the bound form (`Box`) produced by + // `boundGenericTypeName(...)`. The latter makes the specialized + // definition print as `Box` rather than the placeholder + // `Box`, and gives it a unique mangled name per specialization + // (via `mangleAsString(typeName.node)`). + let unboundTypeName = try type.typeName(in: machO) + let finalTypeName: TypeName + if let typeArgumentNodes, !typeArgumentNodes.isEmpty { + finalTypeName = Self.boundGenericTypeName( + unboundTypeName: unboundTypeName, + typeArgumentNodes: typeArgumentNodes + ) + } else { + finalTypeName = unboundTypeName + } + + let specialized = TypeDefinition(type: type, typeName: finalTypeName, isSpecialized: true) + specialized.metadata = metadata + specializedChildren.append(specialized) + return specialized + } + + /// Build a bound-generic `TypeName` by wrapping the supplied unbound + /// (`Type → Structure(...)` / `Class(...)` / `Enum(...)`) form with a + /// `BoundGeneric{Class,Structure,Enum}` node carrying the concrete type + /// argument list. + /// + /// Mirrors the shape Swift's demangler produces at + /// `swift-demangling/.../Demangler.swift:1184` — + /// `Node.create(kind: kind, children: [Node.create(kind: .type, child: n), args])` — + /// so the result round-trips cleanly through `mangleAsString` / + /// `Remangler.mangleBoundGenericStructure`. Both the unbound type and + /// every TypeList entry are normalized to a `Type`-wrapped form because + /// callers occasionally hand us bare `Structure(...)` nodes (the wrap is a + /// no-op when the input is already `.type`). + /// + /// Default access (`internal`) so unit tests in `SwiftInterfaceTests` can + /// exercise the substitution shape without spinning up a full MachO + /// fixture. + static func boundGenericTypeName( + unboundTypeName: TypeName, + typeArgumentNodes: [Node] + ) -> TypeName { + let unboundTypeNode: Node + if unboundTypeName.node.kind == .type { + unboundTypeNode = unboundTypeName.node + } else { + unboundTypeNode = Node.create(kind: .type, children: [unboundTypeName.node]) + } + + let normalizedArgumentNodes: [Node] = typeArgumentNodes.map { argumentNode in + if argumentNode.kind == .type { + return argumentNode + } else { + return Node.create(kind: .type, children: [argumentNode]) + } + } + + let boundKind: Node.Kind + switch unboundTypeName.kind { + case .struct: boundKind = .boundGenericStructure + case .class: boundKind = .boundGenericClass + case .enum: boundKind = .boundGenericEnum + } + + let typeList = Node.create(kind: .typeList, children: normalizedArgumentNodes) + let boundNode = Node.create(kind: boundKind, children: [unboundTypeNode, typeList]) + let wrappedNode = Node.create(kind: .type, children: [boundNode]) + + return TypeName(node: wrappedNode, kind: unboundTypeName.kind) + } + + private func validateSpecialization(metadata: MetadataWrapper, in machO: MachOImage) throws { + // 1. Receiver must be generic. A non-generic descriptor has a + // fixed metadata; specializing it is meaningless and would + // indicate the caller wired the wrong type. + guard type.typeContextDescriptorWrapper.typeContextDescriptor.layout.flags.isGeneric else { + throw SpecializationError.notGenericType(typeName: typeName.name) + } + + // 2. The metadata case must align with the type case. Allow both + // `enum` and `optional` payloads for `.enum` types — Swift + // distinguishes these by metadata kind only, and either can be + // the legitimate output of specializing an enum. + let isCompatibleKind: Bool + switch type { + case .struct: isCompatibleKind = metadata.isStruct + case .enum: isCompatibleKind = metadata.isEnum || metadata.isOptional + case .class: isCompatibleKind = metadata.isClass + } + guard isCompatibleKind else { + throw SpecializationError.metadataKindMismatch( + typeName: typeName.name, + expected: type, + actual: metadata + ) + } + + // 3. Compare descriptor identity. The receiver's descriptor is + // re-resolved into its in-process form via `asPointerWrapper` + // so that the offsets being compared are both process-memory + // addresses. A mismatch means the result was specialized for + // a structurally similar but distinct type. + let inProcessType = type.typeContextDescriptorWrapper.asPointerWrapper(in: machO) + let expectedDescriptorOffset = inProcessType.typeContextDescriptor.offset + let actualDescriptorOffset = try descriptorOffset(of: metadata) + guard expectedDescriptorOffset == actualDescriptorOffset else { + throw SpecializationError.descriptorMismatch( + typeName: typeName.name, + expectedOffset: expectedDescriptorOffset, + actualOffset: actualDescriptorOffset + ) + } + } + + private func descriptorOffset(of metadata: MetadataWrapper) throws -> Int { + switch metadata { + case .struct(let structMetadata): + return try structMetadata.descriptor().contextDescriptor.offset + case .class(let classMetadata): + return try required(classMetadata.descriptor()).offset + case .enum(let enumMetadata), .optional(let enumMetadata), .errorObject(let enumMetadata): + return try enumMetadata.descriptor().contextDescriptor.offset + default: + // Other metadata kinds don't carry a nominal-type descriptor in + // the form we compare against here. Treating this as a hard + // failure (rather than skipping the check silently) makes it + // obvious if a new wrapper case is added without updating this + // switch. + throw SpecializationError.unsupportedMetadataKind(metadata: metadata) + } + } + + /// Errors raised by `specialize(with:in:image:)` when the supplied + /// `SpecializationResult` cannot be reconciled with the receiver. + public enum SpecializationError: LocalizedError { + case notGenericType(typeName: String) + case metadataKindMismatch(typeName: String, expected: TypeContextWrapper, actual: MetadataWrapper) + case descriptorMismatch(typeName: String, expectedOffset: Int, actualOffset: Int) + case unsupportedMetadataKind(metadata: MetadataWrapper) + + public var errorDescription: String? { + switch self { + case .notGenericType(let typeName): + return "Cannot specialize non-generic type '\(typeName)'" + case .metadataKindMismatch(let typeName, let expected, let actual): + return "Specialization metadata for '\(typeName)' has incompatible kind: expected \(expected), got \(actual)" + case .descriptorMismatch(let typeName, let expectedOffset, let actualOffset): + return "Specialization metadata for '\(typeName)' references a different descriptor (expected offset 0x\(String(expectedOffset, radix: 16)), got 0x\(String(actualOffset, radix: 16)))" + case .unsupportedMetadataKind(let metadata): + return "Specialization metadata kind is not supported for descriptor identity validation: \(metadata)" + } + } } package func index(in machO: MachO) async throws { diff --git a/Sources/SwiftInterface/Components/Names/DefinitionName.swift b/Sources/SwiftInterface/Components/Names/DefinitionName.swift index 32c63e78..aea58670 100644 --- a/Sources/SwiftInterface/Components/Names/DefinitionName.swift +++ b/Sources/SwiftInterface/Components/Names/DefinitionName.swift @@ -7,7 +7,11 @@ public protocol DefinitionName { extension DefinitionName { public var name: String { - node.print(using: .interfaceTypeBuilderOnly) + name(using: .interfaceTypeBuilderOnly) + } + + public func name(using options: DemangleOptions) -> String { + node.print(using: options) } public var currentName: String { diff --git a/Sources/SwiftInterface/GenericSpecializer/ConformanceProvider.swift b/Sources/SwiftInterface/GenericSpecializer/ConformanceProvider.swift index 9f1284ac..e3dc840f 100644 --- a/Sources/SwiftInterface/GenericSpecializer/ConformanceProvider.swift +++ b/Sources/SwiftInterface/GenericSpecializer/ConformanceProvider.swift @@ -1,6 +1,8 @@ import Foundation import MachOSwiftSection import OrderedCollections +import Demangling +@_spi(Internals) import SwiftInspection // MARK: - ConformanceProvider Protocol @@ -31,6 +33,19 @@ public protocol ConformanceProvider: Sendable { /// Get image path for a type func imagePath(for typeName: TypeName) -> String? + + /// Returns `baseClassName` together with every direct or transitive + /// subclass of it that the provider knows about. The base class itself + /// is always the first element when the provider recognises it (the + /// `T: T` case trivially satisfies `T: BaseClass`); an empty result + /// means the provider has no information about this type, not that it + /// has no subclasses. + /// + /// Used by `findCandidates` to narrow `` candidate + /// lists. Default implementation returns `[]`, so providers that have + /// no class-hierarchy knowledge degrade to "show every candidate" + /// without breaking the contract. + func subclasses(of baseClassName: TypeName) -> [TypeName] } // MARK: - Default Implementations @@ -51,6 +66,15 @@ extension ConformanceProvider { public func doesType(_ typeName: TypeName, conformToAll protocols: [ProtocolName]) -> Bool { protocols.allSatisfy { doesType(typeName, conformTo: $0) } } + + /// Default conservative implementation — providers that do not index + /// class hierarchy information return an empty list, signalling + /// "unknown" rather than "no subclasses". `findCandidates` interprets + /// the empty result as "do not narrow", keeping the existing + /// "show every candidate" behaviour for non-indexer providers. + public func subclasses(of baseClassName: TypeName) -> [TypeName] { + [] + } } // MARK: - IndexerConformanceProvider @@ -73,6 +97,22 @@ extension ConformanceProvider { public final class IndexerConformanceProvider: @unchecked Sendable { private let indexer: SwiftInterfaceIndexer + /// Lazy cache: superclass canonical-name string → array of direct + /// subclass `TypeName`s. Keyed by `TypeName.name` rather than the + /// `TypeName` itself because demangling the same class through two + /// different mangled-name sources (parameter constraint RHS vs. a + /// child class's `superclassType` link) can produce nominally + /// equivalent `Node` trees that hash differently — e.g. when one + /// side resolves a symbolic reference and the other doesn't, or + /// when caches keep distinct `Node` instances. The print string + /// (`TypeName.name`, "Module.Type") is stable across both paths. + private final class SubclassCache: @unchecked Sendable { + var directChildrenByParentName: [String: [TypeName]]? + let lock = NSLock() + } + + private let subclassCache = SubclassCache() + public init(indexer: SwiftInterfaceIndexer) { self.indexer = indexer } @@ -103,6 +143,80 @@ extension IndexerConformanceProvider: ConformanceProvider { public func imagePath(for typeName: TypeName) -> String? { indexer.allAllTypeDefinitions[typeName]?.machO.imagePath } + + public func subclasses(of baseClassName: TypeName) -> [TypeName] { + // baseClass requirement is irrelevant for non-class subjects — + // return empty so callers can fall back to "do not narrow". + guard baseClassName.kind == .class else { return [] } + + let directChildren = directChildrenMap() + + // BFS over the parent → direct-subclasses graph, keyed by + // canonical name string. Result list still uses `TypeName`s + // (preserving the public API) — the string indirection is + // internal to the cache. + var result: [TypeName] = [baseClassName] + var seenNames: Set = [baseClassName.name] + var queueNames: [String] = [baseClassName.name] + while !queueNames.isEmpty { + let current = queueNames.removeFirst() + for child in directChildren[current] ?? [] { + if seenNames.insert(child.name).inserted { + result.append(child) + queueNames.append(child.name) + } + } + } + return result + } + + /// Build (or fetch from cache) the parent-name → direct-subclasses + /// map by walking every indexed `.class` definition's + /// `superclassType` link. Lock-protected so concurrent first-callers + /// don't both pay the O(n) build cost. + private func directChildrenMap() -> [String: [TypeName]] { + subclassCache.lock.lock() + defer { subclassCache.lock.unlock() } + if let cached = subclassCache.directChildrenByParentName { return cached } + + var map: [String: [TypeName]] = [:] + for (childTypeName, entry) in indexer.allAllTypeDefinitions { + guard childTypeName.kind == .class else { continue } + guard case .class(let classWrapper) = entry.value.type else { continue } + + let superMangled: MangledName? + do { + superMangled = try classWrapper.descriptor.superclassTypeMangledName(in: entry.machO) + } catch { + continue + } + guard let superMangled else { continue } + + // `MetadataReader.demangleType` may wrap the result in a + // `.type` node or return a deeper tree depending on the + // mangled shape. `.first(of: .type)` mirrors how + // `SwiftInterfaceIndexer` itself extracts the type node when + // it indexes extensions/protocol conformances. + // + // Kind is hardcoded `.class` rather than derived from + // `Node.typeKind` for the same reason as + // `baseClassConstraintTypeName`: that helper mis-tags a + // class nested inside a struct as `.struct`. A + // `superclassType` link is class-by-construction (the + // `superclassTypeMangledName` ABI slot only exists on + // class descriptors), so we can commit to `.class` + // unconditionally. + guard let demangledRoot = try? MetadataReader.demangleType(for: superMangled, in: entry.machO), + let superNode = demangledRoot.first(of: .type) else { + continue + } + let superTypeName = TypeName(node: superNode, kind: .class) + map[superTypeName.name, default: []].append(childTypeName) + } + + subclassCache.directChildrenByParentName = map + return map + } } // MARK: - CompositeConformanceProvider @@ -175,6 +289,22 @@ public struct CompositeConformanceProvider: ConformanceProvider { } return nil } + + public func subclasses(of baseClassName: TypeName) -> [TypeName] { + // Merge subclass lists from every provider; dedupe across them + // (a class may legitimately surface in multiple sub-indexers when + // images overlap or conformances are restated). + var seen = Set() + var result: [TypeName] = [] + for provider in providers { + for subclass in provider.subclasses(of: baseClassName) { + if seen.insert(subclass).inserted { + result.append(subclass) + } + } + } + return result + } } // MARK: - EmptyConformanceProvider diff --git a/Sources/SwiftInterface/GenericSpecializer/GenericSpecializer.swift b/Sources/SwiftInterface/GenericSpecializer/GenericSpecializer.swift index 0461cd3b..6f46bd72 100644 --- a/Sources/SwiftInterface/GenericSpecializer/GenericSpecializer.swift +++ b/Sources/SwiftInterface/GenericSpecializer/GenericSpecializer.swift @@ -32,8 +32,21 @@ public final class GenericSpecializer? + /// Indexer for accessing protocol definitions (needed for associated type resolution). + /// + /// Internal visibility — `.boundGeneric` recursion in + /// `makeInnerContext` forwards this into an inner specializer via + /// `init(machO:conformanceProvider:indexer:)`. Do not access from + /// outside the module; the property is not part of the SPI surface. + let indexer: SwiftInterfaceIndexer? + + /// Soft guard against runaway recursion from `Argument.boundGeneric` + /// chains. Defaults to 16 — Swift's own tooling rarely produces + /// well-formed generic nestings beyond a handful of levels, so this + /// is a generous ceiling. Exceeding it produces + /// `SpecializerError.specializationFailed(reason:)` instead of + /// running into stack-bound limits. + public var maxBindingDepth: Int = 16 /// Initialize with an indexer (recommended) public init(indexer: SwiftInterfaceIndexer) { @@ -235,8 +248,19 @@ extension GenericSpecializer { return nil } + // Pull the baseClass requirement (at most one per GP — Swift + // does not allow more than one inheritance constraint) and + // turn its demangled RHS into a `TypeName` so the provider + // can return base-class + subclass list. sameType is + // intentionally *not* converted into a candidate filter: + // its candidate set is genuinely user-determined and can + // span any type, the validate / preflight pass enforces + // consistency. + let baseClassConstraint = Self.baseClassConstraintTypeName(in: requirements) + let candidates = findCandidates( satisfying: protocolRequirements, + boundedBy: baseClassConstraint, options: candidateOptions ) @@ -404,11 +428,13 @@ extension GenericSpecializer { case .sameType: let mangledTypeName = try genericRequirement.type(in: machO) - return .sameType(demangledTypeNode: try MetadataReader.demangleType(for: mangledTypeName, in: machO)) + let demangledTypeNode = try MetadataReader.demangleType(for: mangledTypeName, in: machO) + return .sameType(demangledTypeNode: demangledTypeNode, mangledName: mangledTypeName) case .baseClass: let mangledTypeName = try genericRequirement.type(in: machO) - return .baseClass(demangledTypeNode: try MetadataReader.demangleType(for: mangledTypeName, in: machO)) + let demangledTypeNode = try MetadataReader.demangleType(for: mangledTypeName, in: machO) + return .baseClass(demangledTypeNode: demangledTypeNode, mangledName: mangledTypeName) case .layout: let resolvedContent = try genericRequirement.resolvedContent(in: machO) @@ -517,22 +543,49 @@ extension GenericSpecializer { let path: [String] } - /// Find candidate types that satisfy all protocol constraints. + /// Find candidate types that satisfy all protocol constraints, + /// optionally narrowed to a base-class subtree. /// /// Generic candidates are included by default but flagged via /// `Candidate.isGeneric`; selecting one via `Argument.candidate` would /// throw `candidateRequiresNestedSpecialization` from `specialize`. Pass /// `candidateOptions: .excludeGenerics` to skip them up front when the /// caller wants a "directly-specializable" list. + /// + /// `boundedBy` carries the demangled RHS of a `` + /// requirement. When supplied **and** the conformance provider can + /// answer `subclasses(of:)` (e.g. `IndexerConformanceProvider`), the + /// candidate list is intersected with `BaseClass + every subclass`, + /// stripping out unrelated types up front. If the provider returns an + /// empty subclass list we treat that as "unknown" (rather than "no + /// matches") and fall back to the protocol-only set, so providers + /// without class-hierarchy data degrade gracefully instead of + /// disappearing the candidates entirely. private func findCandidates( satisfying protocols: [ProtocolName], + boundedBy baseClass: TypeName? = nil, options: SpecializationRequest.CandidateOptions = .default ) -> [SpecializationRequest.Candidate] { - let typeNames: [TypeName] + let protocolFiltered: [TypeName] if protocols.isEmpty { - typeNames = conformanceProvider.allTypeNames + protocolFiltered = conformanceProvider.allTypeNames + } else { + protocolFiltered = conformanceProvider.types(conformingToAll: protocols) + } + + let typeNames: [TypeName] + if let baseClass { + let subclassList = conformanceProvider.subclasses(of: baseClass) + if subclassList.isEmpty { + // Provider has no class-hierarchy info — keep the + // pre-baseClass behaviour (do not narrow). + typeNames = protocolFiltered + } else { + let allowed = Set(subclassList) + typeNames = protocolFiltered.filter { allowed.contains($0) } + } } else { - typeNames = conformanceProvider.types(conformingToAll: protocols) + typeNames = protocolFiltered } return typeNames.compactMap { typeName -> SpecializationRequest.Candidate? in @@ -551,6 +604,73 @@ extension GenericSpecializer { ) } } + + /// Returns the demangled RHS of the (at most one) `.baseClass` + /// requirement on a parameter, packaged as a `TypeName` ready for + /// `ConformanceProvider.subclasses(of:)`. Returns nil when the + /// parameter has no baseClass constraint. + /// + /// `kind` is hardcoded to `.class` rather than derived from + /// `Node.typeKind`. The latter scans the entire subtree and matches + /// the *first* of `.enum`/`.structure`/`.class` it finds, which means + /// a class nested inside a struct (`Outer.InnerClass`) is mis-tagged + /// as `.struct`. baseClass requirements only ever resolve to a class + /// at the binary level (Swift rejects `` in Sema), so + /// we can safely commit to the correct kind without inspecting the + /// node — and dodging `Node.typeKind` makes nested class hierarchies + /// (like the test fixtures) work. + static func baseClassConstraintTypeName( + in requirements: [SpecializationRequest.Requirement] + ) -> TypeName? { + for requirement in requirements { + guard case .baseClass(let demangledNode, _) = requirement else { continue } + let typeNode = demangledNode.first(of: .type) ?? demangledNode + return TypeName(node: typeNode, kind: .class) + } + return nil + } + + /// Look up a candidate's `TypeContextDescriptorWrapper` and the image + /// that hosts it. Used by both the bare `.candidate` resolution path + /// (non-generic accessor call) and the `.boundGeneric` recursion path + /// (descriptor feeds an inner `makeRequest`). Throws + /// `SpecializerError.candidateResolutionFailed` when the indexer is + /// missing or doesn't know about the type. + func resolveCandidateDescriptor( + _ candidate: SpecializationRequest.Candidate + ) throws -> (descriptor: TypeContextDescriptorWrapper, machO: MachO) { + guard let indexer else { + throw SpecializerError.candidateResolutionFailed( + candidate: candidate, + reason: "Indexer not available for candidate resolution" + ) + } + guard let typeDefinitionEntry = indexer.allAllTypeDefinitions[candidate.typeName] else { + throw SpecializerError.candidateResolutionFailed( + candidate: candidate, + reason: "Type not found in indexer" + ) + } + return (typeDefinitionEntry.value.type.typeContextDescriptorWrapper, typeDefinitionEntry.machO) + } + + /// Build the descriptor + inner specializer pair that drives + /// `.boundGeneric` recursion. The inner specializer is bound to the + /// candidate's defining image (so `makeRequest`'s `genericContext(in:)` + /// resolves descriptor offsets against the right Mach-O) and shares + /// the outer's conformance provider, indexer, and `maxBindingDepth`. + func makeInnerContext( + for candidate: SpecializationRequest.Candidate + ) throws -> (descriptor: TypeContextDescriptorWrapper, specializer: GenericSpecializer) { + let (descriptor, innerMachO) = try resolveCandidateDescriptor(candidate) + let innerSpecializer = GenericSpecializer( + machO: innerMachO, + conformanceProvider: conformanceProvider, + indexer: indexer + ) + innerSpecializer.maxBindingDepth = maxBindingDepth + return (descriptor, innerSpecializer) + } } // MARK: - Validation @@ -577,11 +697,25 @@ extension GenericSpecializer { /// available when `MachO == MachOImage`). `specialize` automatically /// folds both validations together. public func validate(selection: SpecializationSelection, for request: SpecializationRequest) -> SpecializationValidation { + internalValidate(selection: selection, for: request, parameterPathPrefix: "", depth: 0) + } + + /// Depth + dotted-path-aware validate. Public `validate` enters with + /// an empty prefix and `depth = 0`; `.boundGeneric` recursion forwards + /// a `` prefix and `depth + 1` so nested errors / warnings are + /// reported at their flat dotted parameter paths and the recursion is + /// bounded by `maxBindingDepth`. + func internalValidate( + selection: SpecializationSelection, + for request: SpecializationRequest, + parameterPathPrefix: String, + depth: Int + ) -> SpecializationValidation { let builder = SpecializationValidation.builder() for parameter in request.parameters { guard selection.hasArgument(for: parameter.name) else { - builder.addError(.missingArgument(parameterName: parameter.name)) + builder.addError(.missingArgument(parameterName: Self.joinedPath(parameterPathPrefix, parameter.name))) continue } } @@ -597,14 +731,57 @@ extension GenericSpecializer { // recognizable mistake (associated types are derived during // specialization) and deserves a more actionable warning. if associatedTypePaths.contains(paramName) { - builder.addWarning(.associatedTypePathInSelection(path: paramName)) + builder.addWarning(.associatedTypePathInSelection(path: Self.joinedPath(parameterPathPrefix, paramName))) } else { - builder.addWarning(.extraArgument(parameterName: paramName)) + builder.addWarning(.extraArgument(parameterName: Self.joinedPath(parameterPathPrefix, paramName))) + } + } + + // Recurse into `.boundGeneric` selections so inner-request errors + // surface with dotted parameter paths against the same builder. + for parameter in request.parameters { + guard let argument = selection[parameter.name], + case .boundGeneric(let baseCandidate, let innerArguments) = argument else { + continue + } + let outerPath = Self.joinedPath(parameterPathPrefix, parameter.name) + if depth >= maxBindingDepth { + builder.addError(.metadataResolutionFailed( + parameterName: outerPath, + reason: "binding depth exceeded (maxBindingDepth = \(maxBindingDepth))" + )) + continue + } + do { + let inner = try makeInnerContext(for: baseCandidate) + let innerRequest = try inner.specializer.makeRequest(for: inner.descriptor) + let innerSelection = SpecializationSelection(arguments: innerArguments) + let innerValidation = inner.specializer.internalValidate( + selection: innerSelection, + for: innerRequest, + parameterPathPrefix: outerPath, + depth: depth + 1 + ) + innerValidation.errors.forEach { builder.addError($0) } + innerValidation.warnings.forEach { builder.addWarning($0) } + } catch { + builder.addError(.metadataResolutionFailed( + parameterName: outerPath, + reason: "could not build inner request: \(error)" + )) } } return builder.build() } + + /// Join an outer parameter path prefix with an inner parameter name + /// using `.` as the separator. Empty prefixes yield the inner name + /// untouched so top-level errors continue to read as before. + static func joinedPath(_ prefix: String, _ name: String) -> String { + prefix.isEmpty ? name : "\(prefix).\(name)" + } + } // MARK: - Runtime Preflight @@ -644,13 +821,39 @@ extension GenericSpecializer where MachO == MachOImage { public func runtimePreflight( selection: SpecializationSelection, for request: SpecializationRequest + ) -> SpecializationValidation { + internalRuntimePreflight( + selection: selection, + for: request, + parameterPathPrefix: "", + depth: 0 + ) + } + + /// Depth + dotted-path-aware runtime preflight. Public + /// `runtimePreflight` enters with an empty prefix and `depth = 0`; + /// `.boundGeneric` recursion forwards `` as the prefix and + /// `depth + 1` so nested inner specializations are bounded by + /// `maxBindingDepth` and produce errors / warnings whose parameter + /// names read as flat dotted paths against the same outer builder. + func internalRuntimePreflight( + selection: SpecializationSelection, + for request: SpecializationRequest, + parameterPathPrefix: String, + depth: Int ) -> SpecializationValidation { let builder = SpecializationValidation.builder() + // Pre-pass: resolve every non-candidate parameter's metadata in one + // place so the main pass can index by parameter name. The shared + // map is what enables the GP-vs-GP shape of `sameType` validation — + // when a `where A == B` requirement targets parameter `A`, the + // check needs to compare `A`'s selected metadata against `B`'s. + var metadataByName: [String: Metadata] = [:] for parameter in request.parameters { guard let argument = selection[parameter.name] else { continue } + let outerPath = Self.joinedPath(parameterPathPrefix, parameter.name) - let metadata: Metadata switch argument { case .metatype(let type): // `specialize` runs the same `Metadata.createInProcess` @@ -658,16 +861,15 @@ extension GenericSpecializer where MachO == MachOImage { // call. Surface it now as a typed error rather than a // silent skip — the caller's selection is unusable. do { - metadata = try Metadata.createInProcess(type) + metadataByName[parameter.name] = try Metadata.createInProcess(type) } catch { builder.addError(.metadataResolutionFailed( - parameterName: parameter.name, + parameterName: outerPath, reason: "\(error)" )) - continue } case .metadata(let provided): - metadata = provided + metadataByName[parameter.name] = provided case .specialized(let result): // `SpecializationResult` already carries a resolved metadata // pointer — no accessor call needed; preflight should @@ -675,20 +877,50 @@ extension GenericSpecializer where MachO == MachOImage { // failure here means the supplied result is corrupt and // `specialize` will fail the same way; report as an error. do { - metadata = try result.metadata() + metadataByName[parameter.name] = try result.metadata() } catch { builder.addError(.metadataResolutionFailed( - parameterName: parameter.name, + parameterName: outerPath, reason: "\(error)" )) - continue + } + case .boundGeneric(let baseCandidate, let innerArguments): + // Recursively validate + preflight the inner selection. + // Inner errors/warnings arrive carrying the dotted + // `.` prefix because `collectBoundGenericValidation` + // calls the inner `internalValidate` / + // `internalRuntimePreflight` with `parameterPathPrefix: + // outerPath`. The outer-level aggregation here therefore + // routes the single roll-up under `outerPath` as well. + let outcome = collectBoundGenericValidation( + baseCandidate: baseCandidate, + innerArguments: innerArguments, + parameterPath: outerPath, + depth: depth + ) + outcome.warnings.forEach { builder.addWarning($0) } + if !outcome.errors.isEmpty { + let joined = outcome.errors.map { $0.description }.joined(separator: "; ") + builder.addError(.metadataResolutionFailed( + parameterName: outerPath, + reason: joined + )) + } else if let metadata = outcome.metadata { + metadataByName[parameter.name] = metadata } case .candidate: // The candidate's metadata still requires an accessor call; // leave the actual conformance/layout enforcement to - // `specialize`'s candidate-resolution path. + // `specialize`'s candidate-resolution path. Intentionally + // not entered into the map so cross-parameter checks + // (e.g. sameType) treat it as "unresolved". continue } + } + + for parameter in request.parameters { + guard let metadata = metadataByName[parameter.name] else { continue } + let outerPath = Self.joinedPath(parameterPathPrefix, parameter.name) for requirement in parameter.requirements { switch requirement { @@ -698,7 +930,7 @@ extension GenericSpecializer where MachO == MachOImage { // Surface once per missing-protocol/requirement pair // so the caller knows validation was a no-op. builder.addWarning(.protocolNotInIndexer( - parameterName: parameter.name, + parameterName: outerPath, protocolName: info.protocolName.name )) continue @@ -707,7 +939,7 @@ extension GenericSpecializer where MachO == MachOImage { // Indexer present but the protocol's defining image // isn't included as a sub-indexer. builder.addWarning(.protocolNotInIndexer( - parameterName: parameter.name, + parameterName: outerPath, protocolName: info.protocolName.name )) continue @@ -724,7 +956,7 @@ extension GenericSpecializer where MachO == MachOImage { // Distinct from `protocolNotInIndexer`: the // protocol *is* known but unusable. builder.addError(.protocolDescriptorResolutionFailed( - parameterName: parameter.name, + parameterName: outerPath, protocolName: info.protocolName.name, reason: "\(error)" )) @@ -742,7 +974,7 @@ extension GenericSpecializer where MachO == MachOImage { ) } catch { builder.addWarning(.conformanceCheckFailed( - parameterName: parameter.name, + parameterName: outerPath, protocolName: info.protocolName.name, reason: "\(error)" )) @@ -750,7 +982,7 @@ extension GenericSpecializer where MachO == MachOImage { } if conforms == nil { builder.addError(.protocolRequirementNotSatisfied( - parameterName: parameter.name, + parameterName: outerPath, protocolName: info.protocolName.name, actualType: "\(metadata)" )) @@ -762,21 +994,321 @@ extension GenericSpecializer where MachO == MachOImage { let isClassLike = (kind == .class || kind == .objcClassWrapper || kind == .foreignClass) if !isClassLike { builder.addError(.layoutRequirementNotSatisfied( - parameterName: parameter.name, + parameterName: outerPath, expectedLayout: layoutKind, actualType: "\(metadata)" )) } } - case .protocol, .sameType, .baseClass: - // Other kinds: skip (no PWT, or out-of-scope — see header). + case .baseClass, .sameType, .protocol: + // sameType / baseClass are validated below in the + // unified pass that delegates to runtime substitution + // (handles GP-LHS, dependent-member-LHS, and any RHS + // shape uniformly). ObjC-only `.protocol` requirements + // (no PWT slot) need no check. continue } } } + // Unified sameType / baseClass pass. + // + // Reads requirements directly from the binary's generic context + // (so it covers dependent-member LHS forms like `A.Element == B` + // which `SpecializationRequest` only surfaces via + // `associatedTypeRequirements`, never on the parameter list) and + // resolves both sides through `swift_getTypeByMangledNameInContext`. + // This mirrors what Swift's own `_checkGenericRequirements` does + // (`swift/stdlib/public/runtime/ProtocolConformance.cpp:1846`). + runUnifiedConstraintCheck( + selection: selection, + request: request, + into: builder + ) + return builder.build() } + + /// Walk every binary-level `sameType` / `baseClass` requirement and + /// validate it via runtime substitution. + /// + /// Skipped entirely when the selection contains any `.candidate` — + /// preflight does not run candidate metadata accessors (that path is + /// reserved for `specialize`). Buffer construction errors degrade + /// silently because the same failures already surfaced in the + /// per-parameter pre-pass above. + private func runUnifiedConstraintCheck( + selection: SpecializationSelection, + request: SpecializationRequest, + into builder: SpecializationValidation.Builder + ) { + // Candidate selections require running the candidate's metadata + // accessor; preflight intentionally avoids that side-effect, so + // skip the whole pass. + let hasCandidate = selection.arguments.values.contains { argument in + if case .candidate = argument { return true } + return false + } + if hasCandidate { return } + + // Build the metadata + PWT arrays in canonical order. Failures + // here typically map to errors already reported by the per-param + // pre-pass (missing arg, metadata creation failure, …); silently + // bail out so we don't double-report. + let buffer: (metadatas: [Metadata], witnessTables: [ProtocolWitnessTable], resolvedArguments: [SpecializationResult.ResolvedArgument]) + do { + buffer = try buildKeyArgumentsBuffer(for: request, with: selection) + } catch { + return + } + + // Pack metadatas + PWTs into a flat raw pointer buffer in the + // exact order `swift_getGenericMetadata` (and therefore + // `swift_getTypeByMangledNameInContext`'s substitution) expects. + var rawArguments: [UnsafeRawPointer] = [] + rawArguments.reserveCapacity(buffer.metadatas.count + buffer.witnessTables.count) + do { + for metadata in buffer.metadatas { + rawArguments.append(try metadata.asPointer) + } + for witnessTable in buffer.witnessTables { + rawArguments.append(try witnessTable.asPointer) + } + } catch { + return + } + + let typeDescriptor = request.typeDescriptor.asPointerWrapper(in: machO) + let descriptorPointer: UnsafeRawPointer + do { + descriptorPointer = try typeDescriptor.typeContextDescriptor.asPointer + } catch { + return + } + + guard let genericContext = (try? request.typeDescriptor.genericContext(in: machO)) ?? nil else { + return + } + + let mergedRequirements = Self.mergedRequirements(from: genericContext) + + rawArguments.withUnsafeBufferPointer { argumentsBuffer in + guard let argumentsBase = argumentsBuffer.baseAddress else { return } + let argumentsPointer = UnsafeRawPointer(argumentsBase) + + for requirement in mergedRequirements { + let kind = requirement.layout.flags.kind + guard kind == .sameType || kind == .baseClass else { continue } + + evaluateConstraintRequirement( + kind: kind, + descriptor: requirement, + typeDescriptorPointer: descriptorPointer, + argumentsPointer: argumentsPointer, + into: builder + ) + } + } + } + + /// Resolve LHS / RHS of a single sameType / baseClass requirement via + /// runtime substitution and compare metadata pointers. The display + /// names used in diagnostics come from the demangled node (so + /// `A.Element.Index` reads as written, not as a raw mangled string). + private func evaluateConstraintRequirement( + kind: GenericRequirementKind, + descriptor: GenericRequirementDescriptor, + typeDescriptorPointer: UnsafeRawPointer, + argumentsPointer: UnsafeRawPointer, + into builder: SpecializationValidation.Builder + ) { + let lhsMangled: MangledName + let rhsMangled: MangledName + do { + lhsMangled = try descriptor.paramMangledName(in: machO) + rhsMangled = try descriptor.type(in: machO) + } catch { + return + } + + let lhsDisplay = constraintDisplayName(for: lhsMangled) + let rhsDisplay = constraintDisplayName(for: rhsMangled) + + let lhsResolution = resolveConstraintSide( + mangledName: lhsMangled, + descriptorPointer: typeDescriptorPointer, + argumentsPointer: argumentsPointer + ) + switch lhsResolution { + case .resolved(let lhsType): + let rhsResolution = resolveConstraintSide( + mangledName: rhsMangled, + descriptorPointer: typeDescriptorPointer, + argumentsPointer: argumentsPointer + ) + switch rhsResolution { + case .resolved(let rhsType): + compareConstraintSides( + kind: kind, + lhsType: lhsType, + rhsType: rhsType, + lhsDisplay: lhsDisplay, + rhsDisplay: rhsDisplay, + into: builder + ) + case .unresolved(let reason): + emitResolutionWarning( + kind: kind, parameterName: lhsDisplay, + reason: "could not resolve RHS '\(rhsDisplay)': \(reason)", + into: builder + ) + } + case .unresolved(let reason): + emitResolutionWarning( + kind: kind, parameterName: lhsDisplay, + reason: "could not resolve LHS: \(reason)", + into: builder + ) + } + } + + /// Outcome of trying to resolve a requirement side via runtime + /// substitution. `unresolved` carries a human-readable reason for the + /// warning the caller will emit. + private enum ConstraintResolution { + case resolved(Any.Type) + case unresolved(reason: String) + } + + private func resolveConstraintSide( + mangledName: MangledName, + descriptorPointer: UnsafeRawPointer, + argumentsPointer: UnsafeRawPointer + ) -> ConstraintResolution { + do { + guard let resolvedType = try RuntimeFunctions.getTypeByMangledNameInContext( + mangledName, + genericContext: descriptorPointer, + genericArguments: argumentsPointer, + in: machO + ) else { + return .unresolved(reason: "swift_getTypeByMangledNameInContext returned nil") + } + return .resolved(resolvedType) + } catch { + return .unresolved(reason: "\(error)") + } + } + + /// Generates the readable display string for a side of a constraint — + /// preferred for diagnostics over raw mangled bytes. Falls back to a + /// placeholder when demangling fails (rare; should never block the + /// rest of the validation pipeline). + private func constraintDisplayName(for mangledName: MangledName) -> String { + if let node = try? MetadataReader.demangleType(for: mangledName, in: machO) { + return node.print(using: .interfaceTypeBuilderOnly) + } + return "" + } + + private func compareConstraintSides( + kind: GenericRequirementKind, + lhsType: Any.Type, + rhsType: Any.Type, + lhsDisplay: String, + rhsDisplay: String, + into builder: SpecializationValidation.Builder + ) { + let lhsTypePointer = unsafeBitCast(lhsType, to: UnsafeRawPointer.self) + let rhsTypePointer = unsafeBitCast(rhsType, to: UnsafeRawPointer.self) + + switch kind { + case .sameType: + if lhsTypePointer != rhsTypePointer { + builder.addError(.sameTypeRequirementNotSatisfied( + parameterName: lhsDisplay, + expectedType: "\(rhsType)", + actualType: "\(lhsType)" + )) + } + case .baseClass: + if !isClassDescendantOrSelf( + selectedPointer: lhsTypePointer, + expectedPointer: rhsTypePointer, + lhsType: lhsType + ) { + builder.addError(.baseClassRequirementNotSatisfied( + parameterName: lhsDisplay, + expectedBaseClass: "\(rhsType)", + actualType: "\(lhsType)" + )) + } + default: + break + } + } + + private func emitResolutionWarning( + kind: GenericRequirementKind, + parameterName: String, + reason: String, + into builder: SpecializationValidation.Builder + ) { + switch kind { + case .sameType: + builder.addWarning(.sameTypeRequirementResolutionSkipped( + parameterName: parameterName, + reason: reason + )) + case .baseClass: + builder.addWarning(.baseClassRequirementResolutionFailed( + parameterName: parameterName, + reason: reason + )) + default: + break + } + } + + /// Subclass-or-self test mirroring Swift runtime's `isSubclass` + /// (`swift/stdlib/public/runtime/ProtocolConformance.cpp:1702`): + /// pointer-equality short-circuit, then walk the superclass chain via + /// the universal `AnyClassMetadataObjCInterop.superclass()` accessor + /// (works for pure Swift classes, ObjC class wrappers, and foreign + /// classes alike). + private func isClassDescendantOrSelf( + selectedPointer: UnsafeRawPointer, + expectedPointer: UnsafeRawPointer, + lhsType: Any.Type + ) -> Bool { + if selectedPointer == expectedPointer { return true } + + // The constraint demands a class — value-type metadata can never + // satisfy it. Detect via metadata kind to avoid a misleading + // "superclass walk threw" error. + let lhsMetadata: Metadata + do { + lhsMetadata = try Metadata.createInProcess(lhsType) + } catch { + return false + } + let kind = lhsMetadata.kind + let isClassLike = (kind == .class || kind == .objcClassWrapper || kind == .foreignClass) + guard isClassLike else { return false } + + do { + var current = try AnyClassMetadataObjCInterop.resolve(from: selectedPointer) + while let parent = try current.superclass() { + let parentPointer = try parent.asPointer + if parentPointer == expectedPointer { return true } + current = parent + } + } catch { + return false + } + return false + } + } // MARK: - Specialization Execution @@ -800,35 +1332,211 @@ extension GenericSpecializer where MachO == MachOImage { _ request: SpecializationRequest, with selection: SpecializationSelection, metadataRequest: MetadataRequest = .completeAndBlocking + ) throws -> SpecializationResult { + // Collapse `.boundGeneric` arguments into `.specialized` from the + // leaves up before delegating to `internalSpecialize`. Without this + // pass, every nested binding gets specialized twice per level — + // once inside `internalRuntimePreflight` + // (via `collectBoundGenericValidation`, which needs the inner + // metadata for cross-parameter constraint checks) and once in the + // main path (`buildKeyArgumentsBuffer` → + // `recursivelySpecializeBoundGeneric`). Both branches recurse into + // their inner specializers, so nesting depth N degrades to + // O(2^N) inner specializations. Pre-resolution caches each level's + // result in `.specialized`, restoring linear-in-depth behavior. + let resolvedSelection = try preResolveBoundGenerics(selection: selection, depth: 0) + return try internalSpecialize( + request, + with: resolvedSelection, + metadataRequest: metadataRequest, + depth: 0 + ) + } + + /// Walk the selection depth-first and replace every `.boundGeneric` + /// argument with a `.specialized` argument carrying its already- + /// resolved `SpecializationResult`. Inner selections are processed + /// first so each level's `internalSpecialize` call sees only + /// `.specialized` arguments — `internalValidate`, + /// `internalRuntimePreflight`, and `buildKeyArgumentsBuffer` then have + /// no nested binding chain to recurse into, and the inner accessor + /// runs exactly once per level. + private func preResolveBoundGenerics( + selection: SpecializationSelection, + depth: Int + ) throws -> SpecializationSelection { + var hasBoundGeneric = false + for argument in selection.arguments.values { + if case .boundGeneric = argument { + hasBoundGeneric = true + break + } + } + guard hasBoundGeneric else { return selection } + + var resolvedArguments = selection.arguments + for (parameterName, argument) in selection.arguments { + guard case .boundGeneric(let baseCandidate, let innerArguments) = argument else { + continue + } + if depth >= maxBindingDepth { + throw SpecializerError.specializationFailed( + reason: "binding depth exceeded (maxBindingDepth = \(maxBindingDepth)) at parameter '\(parameterName)'" + ) + } + let result = try resolveBoundGenericNode( + baseCandidate: baseCandidate, + innerArguments: innerArguments, + parameterName: parameterName, + depth: depth + ) + resolvedArguments[parameterName] = .specialized(result) + } + return SpecializationSelection(arguments: resolvedArguments) + } + + /// Specialize one `.boundGeneric` node into a `SpecializationResult`. + /// Recurses through `preResolveBoundGenerics` on the inner selection + /// before invoking the inner `internalSpecialize`, so the inner call + /// itself never re-specializes nested levels. + /// + /// Failures are lifted into `SpecializerError.specializationFailed` + /// with a reason that mirrors the diagnostic + /// `internalRuntimePreflight` would have produced — keeping callers + /// that pattern-match on `.specializationFailed` working unchanged. + private func resolveBoundGenericNode( + baseCandidate: SpecializationRequest.Candidate, + innerArguments: [String: SpecializationSelection.Argument], + parameterName: String, + depth: Int + ) throws -> SpecializationResult { + do { + let inner = try makeInnerContext(for: baseCandidate) + let innerRequest = try inner.specializer.makeRequest(for: inner.descriptor) + let innerSelection = SpecializationSelection(arguments: innerArguments) + let preResolvedInnerSelection = try inner.specializer.preResolveBoundGenerics( + selection: innerSelection, + depth: depth + 1 + ) + return try inner.specializer.internalSpecialize( + innerRequest, + with: preResolvedInnerSelection, + metadataRequest: .completeAndBlocking, + depth: depth + 1 + ) + } catch let SpecializerError.specializationFailed(innerReason) { + // Inner already aggregated through `internalSpecialize`'s own + // validation pipeline — propagate the message under the outer + // parameter path so the joined reason still reads end-to-end. + throw SpecializerError.specializationFailed( + reason: "Could not resolve metadata for parameter '\(parameterName)': \(innerReason)" + ) + } catch { + // `makeInnerContext` / `makeRequest` failures (e.g. selecting a + // non-generic candidate for `.boundGeneric`) used to surface + // via preflight's `metadataResolutionFailed` aggregation. + // Reproduce that wording so existing diagnostics stay stable. + let underlyingMessage = (error as? LocalizedError)?.errorDescription ?? "\(error)" + throw SpecializerError.specializationFailed( + reason: "Could not resolve metadata for parameter '\(parameterName)': could not build inner request: \(underlyingMessage)" + ) + } + } + + /// Depth-aware specialize used to thread `Argument.boundGeneric` + /// recursion through `maxBindingDepth`. Public API enters at `depth = 0`. + /// Inner specializers spawned by `recursivelySpecializeBoundGeneric` + /// call this with `depth + 1` so the soft guard sees the cumulative + /// nesting level across instances. + func internalSpecialize( + _ request: SpecializationRequest, + with selection: SpecializationSelection, + metadataRequest: MetadataRequest, + depth: Int ) throws -> SpecializationResult { let typeDescriptor = request.typeDescriptor.asPointerWrapper(in: machO) // Static validation first (cheap, no runtime resolution). - let staticValidation = validate(selection: selection, for: request) + let staticValidation = internalValidate( + selection: selection, + for: request, + parameterPathPrefix: "", + depth: depth + ) guard staticValidation.isValid else { let errorMessages = staticValidation.errors.map { $0.description }.joined(separator: "; ") throw SpecializerError.specializationFailed(reason: errorMessages) } - // Runtime preflight — verifies protocol conformance and layout - // constraints before we ever call the accessor. Surfaces - // mismatches as `SpecializationValidation.Error` values matching - // the requirement kind, instead of letting them blow up inside - // `swift_getGenericMetadata` or `RuntimeFunctions.conformsToProtocol`. - let runtimeValidation = runtimePreflight(selection: selection, for: request) + // Runtime preflight — verifies protocol conformance, layout, and + // sameType / baseClass constraints before we ever call the + // accessor. Surfaces mismatches as `SpecializationValidation.Error` + // values matching the requirement kind, instead of letting them + // blow up inside `swift_getGenericMetadata` (which doesn't actually + // verify sameType / baseClass — see + // `swift/stdlib/public/runtime/Metadata.cpp:810`). + let runtimeValidation = internalRuntimePreflight( + selection: selection, + for: request, + parameterPathPrefix: "", + depth: depth + ) guard runtimeValidation.isValid else { let errorMessages = runtimeValidation.errors.map { $0.description }.joined(separator: "; ") throw SpecializerError.specializationFailed(reason: errorMessages) } - // Build metadata and witness table arrays in requirement order. - // - // The PWT ordering invariant (still verified by every existing - // fixture): Swift's `compareDependentTypesRec` orders all GP-rooted - // requirements before any nested-type-rooted requirement (see - // `swift/lib/AST/GenericSignature.cpp:846`). That means walking - // direct-GP requirements in parameter order, then walking associated - // requirements in canonical merged-requirement order, reconstructs - // exactly the binary's emission order without an explicit re-sort. + // Build metadata + PWT arrays in canonical (binary) order. + let buffer = try buildKeyArgumentsBuffer(for: request, with: selection, depth: depth) + + // Get metadata accessor function + let accessorFunction = try typeDescriptor.typeContextDescriptor.metadataAccessorFunction() + guard let accessorFunction else { + throw SpecializerError.metadataCreationFailed( + typeName: "unknown", + reason: "Cannot get metadata accessor function" + ) + } + + // Call accessor with metadatas and witness tables + let response = try accessorFunction( + request: metadataRequest, + metadatas: buffer.metadatas, + witnessTables: buffer.witnessTables, + ) + + return SpecializationResult( + metadataPointer: response.value, + resolvedArguments: buffer.resolvedArguments + ) + } + + /// Build the metadata + PWT arrays in canonical (binary) order — the + /// shape that both `swift_getGenericMetadata` (used by the metadata + /// accessor) and `swift_getTypeByMangledNameInContext` (used by the + /// runtime's own `_checkGenericRequirements`, see + /// `swift/stdlib/public/runtime/ProtocolConformance.cpp:1846`) expect. + /// + /// Layout: every direct-GP metadata first, every direct-GP PWT in + /// `Parameter.requirements` order next, every associated-type PWT in + /// `compareDependentTypes` order last. + /// + /// The PWT ordering invariant (verified by every existing fixture): + /// Swift's `compareDependentTypesRec` orders all GP-rooted requirements + /// before any nested-type-rooted requirement (see + /// `swift/lib/AST/GenericSignature.cpp:846`). Walking direct-GP + /// requirements in parameter order, then walking associated + /// requirements in canonical merged-requirement order, reconstructs + /// exactly the binary's emission order without an explicit re-sort. + func buildKeyArgumentsBuffer( + for request: SpecializationRequest, + with selection: SpecializationSelection, + depth: Int = 0 + ) throws -> ( + metadatas: [Metadata], + witnessTables: [ProtocolWitnessTable], + resolvedArguments: [SpecializationResult.ResolvedArgument] + ) { + let typeDescriptor = request.typeDescriptor.asPointerWrapper(in: machO) var metadatas: [Metadata] = [] var witnessTables: [ProtocolWitnessTable] = [] var resolvedArguments: [SpecializationResult.ResolvedArgument] = [] @@ -838,16 +1546,18 @@ extension GenericSpecializer where MachO == MachOImage { throw SpecializerError.specializationFailed(reason: "Missing argument for \(parameter.name)") } - // Resolve metadata for this argument - let metadata = try resolveMetadata(for: argument, parameterName: parameter.name) - metadatas.append(metadata) + let resolved = try resolveArgument( + for: argument, + parameterName: parameter.name, + depth: depth + ) + metadatas.append(resolved.metadata) - // Collect witness tables for protocol requirements (in order) var paramWitnessTables: [ProtocolWitnessTable] = [] for requirement in parameter.requirements { if case .protocol(let info) = requirement, info.requiresWitnessTable { let witnessTable = try resolveWitnessTable( - for: metadata, + for: resolved.metadata, conformingTo: info.protocolName, parameterName: parameter.name ) @@ -858,12 +1568,12 @@ extension GenericSpecializer where MachO == MachOImage { resolvedArguments.append(SpecializationResult.ResolvedArgument( parameterName: parameter.name, - metadata: metadata, - witnessTables: paramWitnessTables + metadata: resolved.metadata, + witnessTables: paramWitnessTables, + innerResult: resolved.innerResult )) } - // Resolve associated type witness tables (in requirement order, appended after parameter PWTs) let metadataByParamName = Dictionary( uniqueKeysWithValues: zip(request.parameters.map(\.name), metadatas) ) @@ -874,12 +1584,11 @@ extension GenericSpecializer where MachO == MachOImage { witnessTables.append(contentsOf: associatedTypeWitnesses) // Defensive invariant — the accessor expects exactly - // `numKeyArguments` slots (metadatas first, then PWTs in canonical - // order). If `buildParameters` / `collectRequirements` / - // `buildAssociatedTypeRequirements` ever miscount, we'd send the - // wrong number of args and the runtime would fail opaquely. - // Reject up front with a typed error so the regression is - // immediately attributable. + // `numKeyArguments` slots. If `buildParameters` / + // `collectRequirements` / `buildAssociatedTypeRequirements` ever + // miscount, we'd send the wrong number of args and the runtime + // would fail opaquely. Reject up front with a typed error so the + // regression is immediately attributable. let totalArguments = metadatas.count + witnessTables.count guard totalArguments == request.keyArgumentCount else { throw SpecializerError.specializationFailed( @@ -887,70 +1596,199 @@ extension GenericSpecializer where MachO == MachOImage { ) } - // Get metadata accessor function - let accessorFunction = try typeDescriptor.typeContextDescriptor.metadataAccessorFunction() - guard let accessorFunction else { - throw SpecializerError.metadataCreationFailed( - typeName: "unknown", - reason: "Cannot get metadata accessor function" - ) - } - - // Call accessor with metadatas and witness tables - let response = try accessorFunction( - request: metadataRequest, - metadatas: metadatas, - witnessTables: witnessTables, - ) - - return SpecializationResult( - metadataPointer: response.value, - resolvedArguments: resolvedArguments - ) + return (metadatas, witnessTables, resolvedArguments) } - /// Resolve metadata from a selection argument - private func resolveMetadata(for argument: SpecializationSelection.Argument, parameterName: String) throws -> Metadata { + /// Resolve metadata from a selection argument, also returning the + /// recursively-resolved `SpecializationResult` when the argument + /// originated from `.boundGeneric` or `.specialized` (so the tree + /// can be surfaced via `ResolvedArgument.innerResult`). + private func resolveArgument( + for argument: SpecializationSelection.Argument, + parameterName: String, + depth: Int + ) throws -> (metadata: Metadata, innerResult: SpecializationResult?) { switch argument { case .metatype(let type): - return try Metadata.createInProcess(type) + return (try Metadata.createInProcess(type), nil) case .metadata(let metadata): - return metadata + return (metadata, nil) case .candidate(let candidate): - return try resolveCandidate(candidate, parameterName: parameterName) + return (try resolveCandidate(candidate, parameterName: parameterName), nil) case .specialized(let result): - return try result.metadata() + return (try result.metadata(), result) + + case .boundGeneric(let baseCandidate, let innerArguments): + let innerResult = try recursivelySpecializeBoundGeneric( + baseCandidate: baseCandidate, + innerArguments: innerArguments, + parameterName: parameterName, + depth: depth + ) + return (try innerResult.metadata(), innerResult) } } - /// Resolve a candidate type to metadata - private func resolveCandidate(_ candidate: SpecializationRequest.Candidate, parameterName: String) throws -> Metadata { - // Find the type definition from indexer - guard let indexer else { - throw SpecializerError.candidateResolutionFailed( - candidate: candidate, - reason: "Indexer not available for candidate resolution" + /// Resolve a `.boundGeneric` selection into a `SpecializationResult` by + /// constructing an inner request from `baseCandidate`'s descriptor and + /// running an inner specializer bound to the candidate's defining + /// image. Throws `SpecializerError.boundGenericInnerFailed` wrapping + /// the underlying error so callers can pattern-match while keeping the + /// inner cause attached. + private func recursivelySpecializeBoundGeneric( + baseCandidate: SpecializationRequest.Candidate, + innerArguments: [String: SpecializationSelection.Argument], + parameterName: String, + depth: Int + ) throws -> SpecializationResult { + if depth >= maxBindingDepth { + throw SpecializerError.specializationFailed( + reason: "binding depth exceeded (maxBindingDepth = \(maxBindingDepth)) at parameter '\(parameterName)'" ) } - // Look up type definition - guard let typeDefinitionEntry = indexer.allAllTypeDefinitions[candidate.typeName] else { - throw SpecializerError.candidateResolutionFailed( - candidate: candidate, - reason: "Type not found in indexer" + let inner: (descriptor: TypeContextDescriptorWrapper, specializer: GenericSpecializer) + do { + inner = try makeInnerContext(for: baseCandidate) + } catch { + throw SpecializerError.boundGenericInnerFailed( + parameterName: parameterName, + underlying: error + ) + } + + let innerRequest: SpecializationRequest + do { + innerRequest = try inner.specializer.makeRequest(for: inner.descriptor) + } catch { + throw SpecializerError.boundGenericInnerFailed( + parameterName: parameterName, + underlying: error ) } - let typeDefinition = typeDefinitionEntry.value - let typeContext = typeDefinition.type.typeContextDescriptorWrapper.typeContextDescriptor + let innerSelection = SpecializationSelection(arguments: innerArguments) + do { + return try inner.specializer.internalSpecialize( + innerRequest, + with: innerSelection, + metadataRequest: .completeAndBlocking, + depth: depth + 1 + ) + } catch { + throw SpecializerError.boundGenericInnerFailed( + parameterName: parameterName, + underlying: error + ) + } + } + + /// Run inner `validate` + inner `runtimePreflight` on a `.boundGeneric` + /// selection so the outer preflight can report inner errors/warnings + /// with dotted parameter paths. When no errors surface, the resolved + /// inner metadata is returned so the caller can populate + /// `metadataByName` for downstream cross-parameter checks (sameType / + /// baseClass via runtime substitution). + private func collectBoundGenericValidation( + baseCandidate: SpecializationRequest.Candidate, + innerArguments: [String: SpecializationSelection.Argument], + parameterPath: String, + depth: Int + ) -> ( + errors: [SpecializationValidation.Error], + warnings: [SpecializationValidation.Warning], + metadata: Metadata? + ) { + if depth >= maxBindingDepth { + return ( + [.metadataResolutionFailed( + parameterName: parameterPath, + reason: "binding depth exceeded (maxBindingDepth = \(maxBindingDepth))" + )], + [], + nil + ) + } + + let inner: (descriptor: TypeContextDescriptorWrapper, specializer: GenericSpecializer) + do { + inner = try makeInnerContext(for: baseCandidate) + } catch { + return ( + [.metadataResolutionFailed(parameterName: parameterPath, reason: "\(error)")], + [], + nil + ) + } + + let innerRequest: SpecializationRequest + do { + innerRequest = try inner.specializer.makeRequest(for: inner.descriptor) + } catch { + return ( + [.metadataResolutionFailed( + parameterName: parameterPath, + reason: "could not build inner request: \(error)" + )], + [], + nil + ) + } + + let innerSelection = SpecializationSelection(arguments: innerArguments) + let innerStatic = inner.specializer.internalValidate( + selection: innerSelection, + for: innerRequest, + parameterPathPrefix: parameterPath, + depth: depth + 1 + ) + let innerRuntime = inner.specializer.internalRuntimePreflight( + selection: innerSelection, + for: innerRequest, + parameterPathPrefix: parameterPath, + depth: depth + 1 + ) + + // `internalValidate` and `internalRuntimePreflight` both produce + // pre-prefixed errors/warnings (they accept `parameterPathPrefix`). + // Concatenating them keeps the dotted-path identity intact end- + // to-end — no `prefixWarning` / `prefixError` pass is needed here. + let combinedErrors = innerStatic.errors + innerRuntime.errors + let combinedWarnings = innerStatic.warnings + innerRuntime.warnings + + if !combinedErrors.isEmpty { + return (combinedErrors, combinedWarnings, nil) + } + + do { + let result = try inner.specializer.internalSpecialize( + innerRequest, + with: innerSelection, + metadataRequest: .completeAndBlocking, + depth: depth + 1 + ) + return ([], combinedWarnings, try result.metadata()) + } catch { + return ( + [.metadataResolutionFailed(parameterName: parameterPath, reason: "\(error)")], + combinedWarnings, + nil + ) + } + } + + /// Resolve a candidate type to metadata + private func resolveCandidate(_ candidate: SpecializationRequest.Candidate, parameterName: String) throws -> Metadata { + let (typeContextWrapper, machO) = try resolveCandidateDescriptor(candidate) + let typeContext = typeContextWrapper.typeContextDescriptor // Generic candidates need nested specialization; surface a typed error // rather than letting the no-argument accessor call below fail with // a generic message. - if let genericContext = try typeContext.genericContext(in: typeDefinitionEntry.machO) { + if let genericContext = try typeContext.genericContext(in: machO) { throw SpecializerError.candidateRequiresNestedSpecialization( candidate: candidate, parameterCount: Int(genericContext.header.numParams) @@ -958,7 +1796,7 @@ extension GenericSpecializer where MachO == MachOImage { } // Get accessor function from type definition's type context - let accessorFunction = try typeContext.metadataAccessorFunction(in: typeDefinitionEntry.machO) + let accessorFunction = try typeContext.metadataAccessorFunction(in: machO) guard let accessorFunction else { throw SpecializerError.candidateResolutionFailed( candidate: candidate, @@ -1281,6 +2119,12 @@ extension GenericSpecializer { case witnessTableNotFound(typeName: String, protocolName: String) case specializationFailed(reason: String) case unsupportedGenericParameter(parameterKind: GenericParamKind) + /// An `Argument.boundGeneric` selection's recursive specialization + /// failed. `parameterName` is the outer parameter the binding was + /// supplied for; `underlying` keeps the inner error's typed identity + /// (often another `SpecializerError`) so callers can still match on + /// the original cause rather than parsing a flattened string. + case boundGenericInnerFailed(parameterName: String, underlying: Swift.Error) public var errorDescription: String? { switch self { @@ -1298,6 +2142,9 @@ extension GenericSpecializer { return "Specialization failed: \(reason)" case .unsupportedGenericParameter(let parameterKind): return "Unsupported generic parameter kind: \(parameterKind). TypePack (variadic generics) and Value generics are not implemented yet." + case .boundGenericInnerFailed(let parameterName, let underlying): + let inner = (underlying as? LocalizedError)?.errorDescription ?? "\(underlying)" + return "Inner specialization for parameter '\(parameterName)' failed: \(inner)" } } } diff --git a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationRequest.swift b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationRequest.swift index 1fd0cdbf..4f62bc43 100644 --- a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationRequest.swift +++ b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationRequest.swift @@ -97,11 +97,22 @@ extension SpecializationRequest { /// Protocol conformance requirement (A: SomeProtocol) - requires PWT case `protocol`(ProtocolRequirementInfo) - /// Same type requirement (A == B) - validation only - case sameType(demangledTypeNode: Node) - - /// Base class requirement (A: SomeClass) - validation only - case baseClass(demangledTypeNode: Node) + /// Same type requirement (A == B) - validation only. + /// + /// Carries both the demangled `Node` (what the API surfaces to UI / + /// users) and the underlying `MangledName` so `runtimePreflight` + /// can hand the RHS to `swift_getTypeByMangledNameInContext` when + /// the RHS is a concrete type. The `Node` half is enough for + /// printing and for spotting "RHS is another generic parameter" + /// shapes; the `MangledName` half is what the runtime accepts. + case sameType(demangledTypeNode: Node, mangledName: MangledName) + + /// Base class requirement (A: SomeClass) - validation only. + /// + /// Carries the same `(Node, MangledName)` pair as `sameType` for + /// the same reason: the preflight superclass-chain walk needs the + /// raw mangled name to resolve the expected base class metadata. + case baseClass(demangledTypeNode: Node, mangledName: MangledName) /// Layout requirement (A: AnyObject) - validation only case layout(LayoutKind) diff --git a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationResult.swift b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationResult.swift index 1833de4e..20d4d655 100644 --- a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationResult.swift +++ b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationResult.swift @@ -53,14 +53,23 @@ extension SpecializationResult { /// Protocol witness tables for protocol constraints public let witnessTables: [ProtocolWitnessTable] + /// Recursively-resolved inner result when the argument originated + /// from `Argument.boundGeneric` or `Argument.specialized`. `nil` for + /// `metatype` / `metadata` / non-generic `candidate`. Enables callers + /// (renderers, snapshot builders) to walk the binding tree without + /// re-deriving it from the original `SpecializationSelection`. + public let innerResult: SpecializationResult? + public init( parameterName: String, metadata: Metadata, - witnessTables: [ProtocolWitnessTable] = [] + witnessTables: [ProtocolWitnessTable] = [], + innerResult: SpecializationResult? = nil ) { self.parameterName = parameterName self.metadata = metadata self.witnessTables = witnessTables + self.innerResult = innerResult } /// Whether this argument has any witness tables diff --git a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationSelection.swift b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationSelection.swift index af2318bc..d780bde4 100644 --- a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationSelection.swift +++ b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationSelection.swift @@ -42,6 +42,20 @@ extension SpecializationSelection { /// Already specialized generic type (for recursive specialization) case specialized(SpecializationResult) + + /// Bind a generic candidate (e.g. `Array`, `Dictionary`) to a nested + /// selection. The specializer recursively builds an inner + /// `SpecializationRequest` from `baseCandidate`'s descriptor and + /// substitutes `innerArguments`; the resulting metadata feeds the + /// outer key-arguments buffer in place of a concrete leaf type. + /// + /// `baseCandidate.isGeneric` must be `true`. Selecting a non-generic + /// candidate via this case produces a typed + /// `SpecializerError.specializationFailed` at specialization time. + case boundGeneric( + baseCandidate: SpecializationRequest.Candidate, + innerArguments: [String: Argument] + ) } } @@ -82,6 +96,23 @@ extension SpecializationSelection { return self } + /// Bind a generic candidate to a nested selection. Equivalent to + /// constructing `Argument.boundGeneric(baseCandidate:innerArguments:)` + /// inline; the specializer expands `innerArguments` into a recursive + /// `SpecializationRequest` substitution on the candidate's descriptor. + @discardableResult + public func set( + _ parameterName: String, + to candidate: SpecializationRequest.Candidate, + boundTo innerArguments: [String: Argument] + ) -> Builder { + arguments[parameterName] = .boundGeneric( + baseCandidate: candidate, + innerArguments: innerArguments + ) + return self + } + /// Remove an argument @discardableResult public func remove(_ parameterName: String) -> Builder { diff --git a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationValidation.swift b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationValidation.swift index fae95893..4f234687 100644 --- a/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationValidation.swift +++ b/Sources/SwiftInterface/GenericSpecializer/Models/SpecializationValidation.swift @@ -53,6 +53,27 @@ extension SpecializationValidation { actualType: String ) + /// Selected type does not inherit from the required base class. + /// Either the type is not a class at all (e.g. a struct supplied + /// for ``), or it is a class whose superclass chain + /// never reaches the expected base class. + case baseClassRequirementNotSatisfied( + parameterName: String, + expectedBaseClass: String, + actualType: String + ) + + /// Selected type does not match a same-type requirement + /// (`A == ConcreteType` or `A == B`). For the GP-vs-GP shape the + /// `expectedType` field carries the *other* parameter's selected + /// type so the message reads symmetrically; the `parameterName` + /// in that case is the LHS of the requirement. + case sameTypeRequirementNotSatisfied( + parameterName: String, + expectedType: String, + actualType: String + ) + /// Could not resolve metadata for the parameter — preflight /// could not run conformance/layout checks. `specialize` runs /// the same metadata resolution path, so the failure is @@ -82,6 +103,12 @@ extension SpecializationValidation { case .layoutRequirementNotSatisfied(let param, let layout, let actual): return "Type '\(actual)' for parameter '\(param)' does not satisfy layout requirement '\(layout)'" + case .baseClassRequirementNotSatisfied(let param, let baseClass, let actual): + return "Type '\(actual)' for parameter '\(param)' does not inherit from required base class '\(baseClass)'" + + case .sameTypeRequirementNotSatisfied(let param, let expected, let actual): + return "Type '\(actual)' for parameter '\(param)' does not equal required same-type '\(expected)'" + case .metadataResolutionFailed(let param, let reason): return "Could not resolve metadata for parameter '\(param)': \(reason)" @@ -123,6 +150,27 @@ extension SpecializationValidation { reason: String ) + /// Could not resolve the RHS of a `baseClass` requirement to a + /// runtime metadata pointer. Preflight cannot enforce the + /// constraint when the RHS mangled name fails to resolve via + /// `swift_getTypeByMangledNameInContext`; surfaced as a warning so + /// the caller knows validation skipped this requirement. + /// `specialize` will still drive the metadata accessor; if the + /// mismatch is real, the runtime will reject it there. + case baseClassRequirementResolutionFailed( + parameterName: String, + reason: String + ) + + /// Could not resolve the RHS of a `sameType` requirement to a + /// runtime metadata pointer (or, for the GP-vs-GP shape, the + /// other parameter's selection). Preflight skips the check; + /// `specialize` continues unchanged. + case sameTypeRequirementResolutionSkipped( + parameterName: String, + reason: String + ) + public var description: String { switch self { case .extraArgument(let param): @@ -133,6 +181,10 @@ extension SpecializationValidation { return "Cannot validate conformance of parameter '\(param)' to '\(proto)': protocol descriptor not found in indexer (add the defining image as a sub-indexer to enable the check)" case .conformanceCheckFailed(let param, let proto, let reason): return "Conformance check for parameter '\(param)' against protocol '\(proto)' failed to run: \(reason)" + case .baseClassRequirementResolutionFailed(let param, let reason): + return "Could not resolve required base class for parameter '\(param)'; preflight skipped the inheritance check: \(reason)" + case .sameTypeRequirementResolutionSkipped(let param, let reason): + return "Could not resolve same-type requirement for parameter '\(param)'; preflight skipped the equality check: \(reason)" } } } diff --git a/Sources/SwiftInterface/GenericSpecializer/REVIEW_FIXUPS.md b/Sources/SwiftInterface/GenericSpecializer/REVIEW_FIXUPS.md deleted file mode 100644 index 6cd318c0..00000000 --- a/Sources/SwiftInterface/GenericSpecializer/REVIEW_FIXUPS.md +++ /dev/null @@ -1,51 +0,0 @@ -# GenericSpecializer — Review Follow-up Fixes - -Tracks the work derived from the `feature/generic-specializer` review. -Item labels match the original review (`M1`–`M12`, `C1`–`C8`). - -## Already fixed (earlier commits on this branch) - -- **H1** — `resolveAssociatedTypeWitnesses` 的 `OrderedDictionary` 分组在不同链解析到同一 leaf metadata 时会破坏 binary PWT 顺序。改回 `[ProtocolWitnessTable]` 线性数组。 -- **C1** — `mergedRequirements` 注释修正:原文断言 conditional invertible 段"只含 `.invertedProtocols`",实际可同时含 marker `.protocol`,注释更新为说明每条过滤路径。 -- **C2** — `SpecializationResult.fieldOffsets()` / `fieldOffsets(in:)` 删除(不是 `SpecializationResult` 的职责)。 -- **C5** — `SpecializerError` 删 3 个未触发 case;`SpecializationValidation.Error` 删 6 个未发出 case;`Warning` 删 2 个未发出 case。 - -## In scope this round - -### Group 1 — zero-cost quick fixes - -- [x] **T1 (C7)** — `IndexerConformanceProvider` doc:必须先 `prepare()` 才能传给 `GenericSpecializer` -- [x] **T2 (C8)** — `GenericSpecializer` doc:`specialize` / `runtimePreflight` 仅在 `MachO == MachOImage` 时可用 -- [x] **T3 (M10)** — 测试:`makeRequest` 在非 generic 类型上应抛 `notGenericType` -- [x] **T4 (M8)** — 测试:`validate` 对未声明的参数发 `.extraArgument` warning -- [x] **T5 (C4)** — 删除 `SpecializationSelection` 的 `init(_:variadic)` 与 `init(_:unlabeled-dict)` 两个重复重载 - -### Group 2 — coverage gaps - -- [x] **T6 (M2a)** — 测试:`Argument.metadata(...)` 成功路径 -- [x] **T7 (M2b)** — 测试:`Argument.candidate(...)` 成功路径(非 generic candidate) -- [x] **T8 (M2c)** — 测试:`Argument.specialized(...)` 递归 specialize(嵌套 generic 类型作为 GP) -- [x] **T9 (M3)** — 测试:三层嵌套 `~Copyable` `specialize` 端到端(fixture 已存在,仅缺 specialize 调用) -- [x] **T10 (M5)** — 新增 `~Escapable` 与 `~Copyable & ~Escapable` fixture + 测试(dual 用空 enum,由于工具链 bug 暂去掉条件扩展) - -### Group 3 — enum / class coverage - -- [x] **T11 (M1a)** — 新增 `TestGenericEnum` fixture + makeRequest / specialize 测试 -- [x] **T12 (M1b)** — 新增 `TestGenericClass` fixture + makeRequest / specialize 测试 - -### Final - -- [ ] **T13** — 跑 `swift test --filter GenericSpecializationTests` 确认全绿;跑 `swift build` 全包确认无外部回归 - -## Deferred (not fixed this round, recorded for record) - -| 项 | 原因 | -|---|---| -| M4 (`where A == B` / sameType / baseClass non-AnyObject) | fixture 是否能 compile 本身需要验证;ROI 与风险不匹配 | -| M6 (`CompositeConformanceProvider` / `StandardLibraryConformanceProvider` 单测) | 简单 wrapper,价值有限 | -| M7 (`metadata()` / `valueWitnessTable()` / `argument(for:)` / `fullPath` 公开 API caller 测试) | 隐式被 M2 / M3 覆盖 | -| M9 (`Outer.Inner` 内层多 GP) | 路径已被 `perLevelNewParameterCounts` 覆盖;fixture 投入大 | -| M11 (marker / ObjC-only protocol silent skip) | 边角行为,目前没观察到 bug | -| M12 (`runtimePreflight` indexer 缺失协议时静默 skip) | 边角行为 | -| C3 (`StandardLibraryConformanceProvider` doc 警告) | 可在使用方加注释,非紧急 | -| C6 (`extractAssociatedPath` 防御日志) | 纯防御,未被触发 | diff --git a/Sources/SwiftInterface/NodePrintables/InterfaceNodePrintable.swift b/Sources/SwiftInterface/NodePrintables/InterfaceNodePrintable.swift index 1c79af7e..28eea4eb 100644 --- a/Sources/SwiftInterface/NodePrintables/InterfaceNodePrintable.swift +++ b/Sources/SwiftInterface/NodePrintables/InterfaceNodePrintable.swift @@ -1,9 +1,8 @@ import Demangling -import Semantic import MemberwiseInit protocol InterfaceNodePrintable: NodePrintable, BoundGenericNodePrintable, TypeNodePrintable, DependentGenericNodePrintable, FunctionTypeNodePrintable { - mutating func printRoot(_ node: Node) async throws -> SemanticString + mutating func printRoot(_ node: Node) async throws -> Target } protocol InterfaceNodePrintableContext: NodePrintableContext, FunctionTypeNodePrintableContext {} @@ -19,6 +18,42 @@ struct InterfaceNodePrinterContext: InterfaceNodePrintableContext { extension InterfaceNodePrintable { mutating func printName(_ name: Node, asPrefixContext: Bool, context: Context?) async -> Node? { + if printDepth > Self.maxPrintDepth { + target.write("<>") + return nil + } + // Memoize only "default-context" prints. Sub-method prints that depend + // on caller-side state (asPrefixContext, custom context, an active + // dependentMemberType chain) can produce different output for the same + // node and so must not be served from cache. The DAG-explosion case + // we care about (BoundGeneric typeList children) always recurses + // through this default path, so the cache still kicks in there. + let cacheKey = ObjectIdentifier(name) + let canCache = !asPrefixContext && context == nil && dependentMemberTypeDepth == 0 + if canCache, let cached = printCache[cacheKey] { + target.append(cached) + return nil + } + printDepth += 1 + defer { printDepth -= 1 } + if canCache { + // Redirect output to a fresh sub-target so we can capture exactly + // the slice produced for `name` and memoize it. The `swap` keeps + // `self.target` as the live target for nested print calls (which + // mutate `self`), then we swap back and splice the captured + // fragment into the original target. + var subTarget = Target() + swap(&target, &subTarget) + let result = await dispatchPrintName(name, context: context) + swap(&target, &subTarget) + printCache[cacheKey] = subTarget + target.append(subTarget) + return result + } + return await dispatchPrintName(name, context: context) + } + + private mutating func dispatchPrintName(_ name: Node, context: Context?) async -> Node? { if await printNameInBase(name, context: context) { return nil } diff --git a/Sources/SwiftInterface/NodePrintables/NodePrintable.swift b/Sources/SwiftInterface/NodePrintables/NodePrintable.swift index aafaf961..1e1f4afe 100644 --- a/Sources/SwiftInterface/NodePrintables/NodePrintable.swift +++ b/Sources/SwiftInterface/NodePrintables/NodePrintable.swift @@ -17,10 +17,36 @@ protocol NodePrintable { var dependentMemberTypeDepth: Int { get set } + /// Mirrors the ``Swift::Demangle::NodePrinter`` recursion guard at + /// ``swift/lib/Demangling/NodePrinter.cpp:1416``. Each entry into + /// ``printName(_:asPrefixContext:context:)`` increments the counter and + /// the wrapper bails with ``<>`` once it would exceed + /// ``maxPrintDepth``. Without this, demangle results that share substitution + /// nodes (a DAG) blow up into ``19^k``-shaped traversals during printing. + var printDepth: Int { get set } + + /// Memoization for shared substitution nodes. The demangler returns the + /// same ``Node`` instance for every back-reference (e.g. ``A23_``), so a + /// single ``Type<...>`` mangling can produce a DAG that, naively walked + /// child-by-child, expands into hundreds of thousands of node visits. By + /// caching the rendered ``SemanticString`` slice keyed by + /// ``ObjectIdentifier(node)``, every shared node prints once and reuses + /// the cached fragment thereafter — bringing print cost back to the size + /// of the unique node set instead of the exponential expansion. The + /// cache is per ``NodePrintable`` instance, so it lives only for the + /// duration of one ``printRoot`` invocation. + var printCache: [ObjectIdentifier: Target] { get set } + @discardableResult mutating func printName(_ name: Node, asPrefixContext: Bool, context: Context?) async -> Node? } +extension NodePrintable { + /// Single-path recursion budget, matching ``Swift::Demangle::NodePrinter::MaxDepth`` + /// in ``swift/include/swift/Demangling/Demangle.h``. + static var maxPrintDepth: Int { 768 } +} + extension NodePrintable { mutating func printNameInBase(_ name: Node, context: Context?) async -> Bool { switch name.kind { diff --git a/Sources/SwiftInterface/NodePrinter/FunctionNodePrinter.swift b/Sources/SwiftInterface/NodePrinter/FunctionNodePrinter.swift index c21c16b8..4361fa41 100644 --- a/Sources/SwiftInterface/NodePrinter/FunctionNodePrinter.swift +++ b/Sources/SwiftInterface/NodePrinter/FunctionNodePrinter.swift @@ -5,8 +5,10 @@ import Semantic struct FunctionNodePrinter: InterfaceNodePrintable { typealias Context = InterfaceNodePrinterContext + + typealias Target = SemanticString - var target: SemanticString = "" + var target: Target = "" private var isStatic: Bool = false @@ -18,6 +20,10 @@ struct FunctionNodePrinter: InterfaceNodePrintable { var dependentMemberTypeDepth: Int = 0 + var printDepth: Int = 0 + + var printCache: [ObjectIdentifier: Target] = [:] + private(set) var targetNode: Node? init(isOverride: Bool, delegate: (any NodePrintableDelegate)? = nil) { diff --git a/Sources/SwiftInterface/NodePrinter/SubscriptNodePrinter.swift b/Sources/SwiftInterface/NodePrinter/SubscriptNodePrinter.swift index 58f83de8..5192dd62 100644 --- a/Sources/SwiftInterface/NodePrinter/SubscriptNodePrinter.swift +++ b/Sources/SwiftInterface/NodePrinter/SubscriptNodePrinter.swift @@ -4,6 +4,8 @@ import Semantic struct SubscriptNodePrinter: InterfaceNodePrintable { typealias Context = InterfaceNodePrinterContext + + typealias Target = SemanticString var target: SemanticString = "" @@ -21,6 +23,10 @@ struct SubscriptNodePrinter: InterfaceNodePrintable { var dependentMemberTypeDepth: Int = 0 + var printDepth: Int = 0 + + var printCache: [ObjectIdentifier: Target] = [:] + private(set) var targetNode: Node? init(isOverride: Bool, hasSetter: Bool, indentation: Int, delegate: (any NodePrintableDelegate)? = nil) { diff --git a/Sources/SwiftInterface/NodePrinter/TypeNodePrinter.swift b/Sources/SwiftInterface/NodePrinter/TypeNodePrinter.swift index b58b4538..3a6abef5 100644 --- a/Sources/SwiftInterface/NodePrinter/TypeNodePrinter.swift +++ b/Sources/SwiftInterface/NodePrinter/TypeNodePrinter.swift @@ -4,6 +4,8 @@ import Semantic struct TypeNodePrinter: InterfaceNodePrintable { typealias Context = InterfaceNodePrinterContext + + typealias Target = SemanticString var target: SemanticString = "" @@ -13,6 +15,10 @@ struct TypeNodePrinter: InterfaceNodePrintable { var dependentMemberTypeDepth: Int = 0 + var printDepth: Int = 0 + + var printCache: [ObjectIdentifier: Target] = [:] + private(set) weak var delegate: (any NodePrintableDelegate)? init(delegate: (any NodePrintableDelegate)? = nil, isProtocol: Bool = false) { diff --git a/Sources/SwiftInterface/NodePrinter/VariableNodePrinter.swift b/Sources/SwiftInterface/NodePrinter/VariableNodePrinter.swift index 8298bc04..52ec53fb 100644 --- a/Sources/SwiftInterface/NodePrinter/VariableNodePrinter.swift +++ b/Sources/SwiftInterface/NodePrinter/VariableNodePrinter.swift @@ -4,8 +4,10 @@ import Semantic struct VariableNodePrinter: InterfaceNodePrintable { typealias Context = InterfaceNodePrinterContext + + typealias Target = SemanticString - var target: SemanticString = "" + var target: Target = "" private var isStatic: Bool = false @@ -25,6 +27,10 @@ struct VariableNodePrinter: InterfaceNodePrintable { var dependentMemberTypeDepth: Int = 0 + var printDepth: Int = 0 + + var printCache: [ObjectIdentifier: Target] = [:] + init(isStored: Bool, isOverride: Bool, hasSetter: Bool, indentation: Int, delegate: (any NodePrintableDelegate)? = nil) { self.isStored = isStored self.isOverride = isOverride diff --git a/Sources/SwiftInterface/Components/SemanticComponents.swift b/Sources/SwiftInterface/SemanticExtensions/SemanticComponents.swift similarity index 100% rename from Sources/SwiftInterface/Components/SemanticComponents.swift rename to Sources/SwiftInterface/SemanticExtensions/SemanticComponents.swift diff --git a/Sources/SwiftInterface/SwiftInterfaceBuilder.swift b/Sources/SwiftInterface/SwiftInterfaceBuilder.swift index ac8d960c..6cebf339 100644 --- a/Sources/SwiftInterface/SwiftInterfaceBuilder.swift +++ b/Sources/SwiftInterface/SwiftInterfaceBuilder.swift @@ -140,6 +140,21 @@ public final class SwiftInterfaceBuilder StructDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.struct?.name(in: machO).contains(nameContains) == true + }?.struct, + "expected a struct descriptor whose name contains \"\(nameContains)\"" + ) + } + + private func enumDescriptor(named nameContains: String) throws -> EnumDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.enum?.name(in: machO).contains(nameContains) == true + }?.enum, + "expected an enum descriptor whose name contains \"\(nameContains)\"" + ) + } + + private func classDescriptor(named nameContains: String) throws -> ClassDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.class?.name(in: machO).contains(nameContains) == true + }?.class, + "expected a class descriptor whose name contains \"\(nameContains)\"" + ) + } + + // MARK: - Struct + + @Test("specialized struct dump renders concrete field type instead of generic param") + func specializedStructFieldShowsConcreteType() async throws { + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedFields = try await dumper.fields.string + + // The unbound source was `let value: A`. Substituted output must + // surface `Int` somewhere in the field block. + #expect(renderedFields.contains("Int"), + "expected substituted concrete type 'Int' in fields; got: \(renderedFields)") + } + + @Test("two specializations of one struct render distinct concrete field types") + func twoStructSpecializationsShowDistinctTypes() async throws { + _ = Fixtures.SingleParameterBox.self + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + + let intMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let intContext = DumperMetadataContext(metadata: intMetadata, readingContext: InProcessContext.shared) + let intDumper = StructDumper(structValue, metadataContext: intContext, using: configuration, in: machO) + let intFields = try await intDumper.fields.string + + let stringMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let stringContext = DumperMetadataContext(metadata: stringMetadata, readingContext: InProcessContext.shared) + let stringDumper = StructDumper(structValue, metadataContext: stringContext, using: configuration, in: machO) + let stringFields = try await stringDumper.fields.string + + // Each specialization must point at its own resolved concrete type + // — same dumper class, same descriptor, but different metadata + // contexts feed different substitution paths. + #expect(intFields.contains("Int")) + #expect(stringFields.contains("String")) + #expect(intFields != stringFields, + "two specializations should render different field text; both produced: \(intFields)") + } + + @Test("specialized struct substitutes inside Array field") + func specializedStructSubstitutesIntoArrayField() async throws { + _ = Fixtures.GenericArrayWrapper.self + + let descriptor = try structDescriptor(named: "GenericArrayWrapper") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.GenericArrayWrapper.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedFields = try await dumper.fields.string + + // The unbound source was `let values: [A]`. After substitution the + // rendered text must mention Double (inside whatever Array form + // the demangler produces — e.g. "[Double]" or "Array"). + #expect(renderedFields.contains("Double"), + "expected substituted Double in Array field; got: \(renderedFields)") + } + + // MARK: - Enum + + @Test("specialized enum dump renders concrete payload type") + func specializedEnumPayloadShowsConcreteType() async throws { + _ = Fixtures.GenericResultEnum.self + + let descriptor = try enumDescriptor(named: "GenericResultEnum") + let enumValue = try Enum(descriptor: descriptor, in: machO) + let specializedMetadata = try EnumMetadata.createInProcess(Fixtures.GenericResultEnum.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = EnumDumper(enumValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedFields = try await dumper.fields.string + + // `case success(A)` substitutes A=Int; the rendered case payload + // must mention Int rather than the generic param. + #expect(renderedFields.contains("Int"), + "expected substituted Int in enum payload; got: \(renderedFields)") + } + + // MARK: - Class + + @Test("specialized class dump renders concrete field type") + func specializedClassFieldShowsConcreteType() async throws { + _ = Fixtures.GenericContainerClass.self + + let descriptor = try classDescriptor(named: "GenericContainerClass") + let classValue = try Class(descriptor: descriptor, in: machO) + let specializedMetadata = try ClassMetadataObjCInterop.createInProcess(Fixtures.GenericContainerClass.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = ClassDumper(classValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedFields = try await dumper.fields.string + + #expect(renderedFields.contains("Int"), + "expected substituted Int in class field; got: \(renderedFields)") + } + + // MARK: - Declaration substitution + + @Test("specialized struct declaration shows bound generic name and skips signature") + func specializedStructDeclarationShowsBoundName() async throws { + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedDeclaration = try await dumper.declaration.string + + // Bound name carries the type argument; the unbound parameter name + // `A` must NOT appear (would imply we re-emitted the generic clause). + #expect(renderedDeclaration.contains("Int"), + "expected bound declaration to mention Int; got: \(renderedDeclaration)") + #expect(!renderedDeclaration.contains(""), + "expected bound declaration to drop the unbound `` form; got: \(renderedDeclaration)") + } + + @Test("two specializations render distinct declarations from the same descriptor") + func twoSpecializationsRenderDistinctDeclarations() async throws { + _ = Fixtures.SingleParameterBox.self + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + + let intMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let intDumper = StructDumper( + structValue, + metadataContext: DumperMetadataContext(metadata: intMetadata, readingContext: InProcessContext.shared), + using: configuration, + in: machO + ) + let intDeclaration = try await intDumper.declaration.string + + let stringMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let stringDumper = StructDumper( + structValue, + metadataContext: DumperMetadataContext(metadata: stringMetadata, readingContext: InProcessContext.shared), + using: configuration, + in: machO + ) + let stringDeclaration = try await stringDumper.declaration.string + + #expect(intDeclaration.contains("Int")) + #expect(stringDeclaration.contains("String")) + #expect(intDeclaration != stringDeclaration, + "different specializations should produce distinct declarations; both produced: \(intDeclaration)") + } + + @Test("specialized class declaration shows bound generic name") + func specializedClassDeclarationShowsBoundName() async throws { + _ = Fixtures.GenericContainerClass.self + + let descriptor = try classDescriptor(named: "GenericContainerClass") + let classValue = try Class(descriptor: descriptor, in: machO) + let specializedMetadata = try ClassMetadataObjCInterop.createInProcess(Fixtures.GenericContainerClass.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = ClassDumper(classValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedDeclaration = try await dumper.declaration.string + + #expect(renderedDeclaration.contains("Int"), + "expected bound class declaration to mention Int; got: \(renderedDeclaration)") + #expect(!renderedDeclaration.contains(""), + "expected bound class declaration to drop unbound `` form; got: \(renderedDeclaration)") + } + + @Test("non-specialized struct declaration keeps unbound generic clause") + func nonSpecializedStructDeclarationKeepsUnboundClause() async throws { + // Sanity: when the dumper has no metadataContext, declaration must + // fall back to the existing unbound path — the bound substitution + // is gated on the in-process specialized metadata being present. + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + + let dumper = StructDumper(structValue, using: configuration, in: machO) + let renderedDeclaration = try await dumper.declaration.string + + // Without a metadata context, we expect the unbound generic clause + // (e.g. ``) to appear and no concrete substitution. + #expect(renderedDeclaration.contains(""), + "expected unbound declaration to keep ``; got: \(renderedDeclaration)") + } + + // MARK: - Expanded-field-offset substitution + + @Test("expanded field offsets substitute generic params at the top hop") + func expandedFieldOffsetsSubstituteAtTopHop() async throws { + _ = Fixtures.NestedStructHostingStruct.self + + let descriptor = try structDescriptor(named: "NestedStructHostingStruct") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.NestedStructHostingStruct.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + // Turn on the expanded-field-offset path so the dumper actually + // walks nested fields under each top-level field. + var expandedConfig = configuration + expandedConfig.printFieldOffset = true + expandedConfig.printExpandedFieldOffsets = true + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: expandedConfig, in: machO) + let body = try await dumper.body.string + + // Expanded-offset comments walk *into* each field's type; for + // `inner: SingleParameterBox` substituted to + // `SingleParameterBox`, the first nested comment line is the + // box's `value: A` field. Substitution must propagate through the + // top-level dumper context so we see `value (Swift.Int)` rather + // than the unbound `value (A)` form. + // + // Pre-fix, the top hop's bare `getTypeByMangledNameInContext` can't + // resolve the generic `inner` type, the gate `!descriptor.isGeneric` + // would also reject the resolved generic struct, and we'd see no + // expanded line at all. + #expect(body.contains("value (Swift.Int)") || body.contains("value (Int)"), + "expected expanded line to substitute A → Int; got: \(body)") + } + + @Test("expanded field offsets recurse with nested specialized metadata") + func expandedFieldOffsetsRecurseWithNestedMetadata() async throws { + _ = Fixtures.NestedStructHostingStruct.self + + let descriptor = try structDescriptor(named: "NestedStructHostingStruct") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.NestedStructHostingStruct.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + var expandedConfig = configuration + expandedConfig.printFieldOffset = true + expandedConfig.printExpandedFieldOffsets = true + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: expandedConfig, in: machO) + let body = try await dumper.body.string + + // The recursive walk needs the *nested* `SingleParameterBox` + // metadata as the substitution context for its `value: A` field. + // If recursion fell back to the bare resolver, `value` would render + // with the unbound `A`, so we'd see "value (A):" rather than the + // substituted form below. + #expect(body.contains("value (Double):") || body.contains("value (Swift.Double):"), + "expected nested expanded line to substitute A → Double; got: \(body)") + } + + // MARK: - Bound declaration semantic styling + + @Test("specialized name keeps inner type arguments at .name (not .declaration)") + func specializedNameKeepsInnerArgumentsAtNameContext() async throws { + // The bound name `SingleParameterBox` ought to look semantically + // like the unbound declaration `SingleParameterBox` plus a + // *type-reference* `Int` inside `<...>` — exactly the way a regular + // type reference is rendered elsewhere. Pre-fix, the blanket + // `replacingTypeNameOrOtherToTypeDeclaration()` walk upgraded every + // nested `.type(_, .name)` to `.type(_, .declaration)`, so the inner + // `Int` ended up tagged as a declaration too. This test pins that + // the head and the inner argument now carry distinct semantic + // contexts. + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: configuration, in: machO) + let renderedName = try await dumper.name + + var declarationStringSegments: [String] = [] + var nameStringSegments: [String] = [] + for component in renderedName.components { + switch component.type { + case .type(_, .declaration): + declarationStringSegments.append(component.string) + case .type(_, .name): + nameStringSegments.append(component.string) + default: + continue + } + } + let declarationJoined = declarationStringSegments.joined() + let nameJoined = nameStringSegments.joined() + + // Outer dumped type appears in the `.declaration` segments. + #expect(declarationJoined.contains("SingleParameterBox"), + "expected outer head in .declaration components; got declarations: \(declarationJoined)") + // Inner argument `Int` appears in the `.name` segments — not in + // `.declaration`. + #expect(nameJoined.contains("Int"), + "expected inner Int in .name components; got names: \(nameJoined)") + #expect(!declarationJoined.contains("Int"), + "Int leaked into .declaration components: \(declarationJoined)") + } + + @Test("expanded field offsets do not crash when a nested field is a class") + func expandedFieldOffsetsHandlesClassFieldWithoutCrash() async throws { + // Regression: pre-fix, the recursion happily called + // `StructMetadata.createInProcess` on a class metatype, producing a + // misaligned `StructMetadata`. The next iteration's + // `structDescriptor()` then triggered an internal `descriptor().struct!` + // force-unwrap and trapped (a `try?` does not catch a force-unwrap + // trap). The kind-checked `structMetadata(forMetatype:)` helper + // returns nil for class metatypes, so recursion stops cleanly. + _ = Fixtures.StructHostingClassField.self + + let descriptor = try structDescriptor(named: "StructHostingClassField") + let structValue = try Struct(descriptor: descriptor, in: machO) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.StructHostingClassField.self) + let metadataContext = DumperMetadataContext(metadata: specializedMetadata, readingContext: InProcessContext.shared) + + var expandedConfig = configuration + expandedConfig.printFieldOffset = true + expandedConfig.printExpandedFieldOffsets = true + + let dumper = StructDumper(structValue, metadataContext: metadataContext, using: expandedConfig, in: machO) + // The assertion is just "did not crash". We also sanity-check that + // the top-level field rendering still substituted the class + // reference's generic argument so we know we didn't accidentally + // bail out before producing useful output. + let body = try await dumper.body.string + #expect(body.contains("GenericContainerClass") || body.contains("GenericContainerClass"), + "expected class field to render with substituted generic; got: \(body)") + } +} diff --git a/Tests/MachOSwiftSectionTests/SpecializedMangledNameResolutionTests.swift b/Tests/MachOSwiftSectionTests/SpecializedMangledNameResolutionTests.swift new file mode 100644 index 00000000..fa8b21d9 --- /dev/null +++ b/Tests/MachOSwiftSectionTests/SpecializedMangledNameResolutionTests.swift @@ -0,0 +1,405 @@ +import Foundation +import Testing +import MachOKit +@testable import MachOSwiftSection +@testable import MachOTestingSupport + +// MARK: - Specialized mangled-name resolution + +/// Exercises `RuntimeFunctions.getTypeByMangledNameInContext(_:specializedFrom:in:)` +/// — the helper that resolves a field's mangled type name to a concrete +/// `Any.Type` by deriving the descriptor pointer and the inline +/// generic-arguments array from a specialized in-process metadata. +/// +/// Fixtures come from `MachOTestingSupport.SpecializedMangledNameFixtures` so +/// the descriptors live in the shared support module and stay reusable across +/// future suites. Each test still touches `T.self` before reading the +/// metadata back so the runtime materializes the per-instantiation metadata. +@Suite(.serialized) +struct SpecializedMangledNameResolutionTests { + private typealias Fixtures = SpecializedMangledNameFixtures + + // MARK: - Helpers + + private var machO: MachOImage { .current() } + + /// Locates the struct descriptor whose name contains `nameContains`. + /// Substring matching keeps the lookup tolerant of the module-qualified + /// nested-type prefix that the linker writes into the binary. + private func structDescriptor(named nameContains: String) throws -> StructDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.struct?.name(in: machO).contains(nameContains) == true + }?.struct, + "expected a struct descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Locates the enum descriptor whose name contains `nameContains`. + private func enumDescriptor(named nameContains: String) throws -> EnumDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.enum?.name(in: machO).contains(nameContains) == true + }?.enum, + "expected an enum descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Locates the class descriptor whose name contains `nameContains`. + private func classDescriptor(named nameContains: String) throws -> ClassDescriptor { + try #require( + try machO.swift.typeContextDescriptors.first { + try $0.class?.name(in: machO).contains(nameContains) == true + }?.class, + "expected a class descriptor whose name contains \"\(nameContains)\"" + ) + } + + /// Reads the mangled type name of the field at `fieldIndex` inside the + /// descriptor's field descriptor. Asserts the index is in range so the + /// test fails with a clear message rather than a generic out-of-bounds + /// trap if the fixture's field count drifts. + private func fieldMangledTypeName( + of descriptor: Descriptor, + atFieldIndex fieldIndex: Int + ) throws -> MangledName { + let fieldDescriptor = try descriptor.fieldDescriptor(in: machO) + let records = try fieldDescriptor.records(in: machO) + try #require( + fieldIndex < records.count, + "expected at least \(fieldIndex + 1) field record(s); fixture had \(records.count)" + ) + return try records[fieldIndex].mangledTypeName(in: machO) + } + + // MARK: - Single generic parameter + + @Test("resolves a single generic parameter to Int") + func resolvesSingleParameterToInt() throws { + // Force per-instantiation metadata emission. `T.self` is enough — + // the runtime materializes the specialized metadata before returning + // the metatype. + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(Int.self)) + } + + @Test("resolves the same parameter to a different concrete type per specialization") + func resolvesSingleParameterToString() throws { + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(String.self)) + } + + // MARK: - Multiple generic parameters + + @Test("respects positional ordering of two generic parameters") + func respectsPositionalOrderingForTwoParameters() throws { + _ = Fixtures.TwoParameterPair.self + + let descriptor = try structDescriptor(named: "TwoParameterPair") + let firstFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let secondFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 1) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.TwoParameterPair.self) + + let firstResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + firstFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + let secondResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + secondFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(firstResolved) == ObjectIdentifier(Int.self)) + #expect(ObjectIdentifier(secondResolved) == ObjectIdentifier(String.self)) + } + + @Test("flipping the parameter order yields the swapped resolution") + func flippedTwoParameterOrderingResolvesCorrectly() throws { + // `TwoParameterPair` shares the same unbound descriptor + // as the previous test but with the substitutions reversed. The + // helper must read the array in metadata order — not pull from a + // cache keyed on the descriptor alone. + _ = Fixtures.TwoParameterPair.self + + let descriptor = try structDescriptor(named: "TwoParameterPair") + let firstFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let secondFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 1) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.TwoParameterPair.self) + + let firstResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + firstFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + let secondResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + secondFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(firstResolved) == ObjectIdentifier(String.self)) + #expect(ObjectIdentifier(secondResolved) == ObjectIdentifier(Int.self)) + } + + // MARK: - Generic parameters inside compound types + + @Test("substitutes the generic parameter inside Array") + func substitutesIntoArrayOfGenericParameter() throws { + _ = Fixtures.GenericArrayWrapper.self + + let descriptor = try structDescriptor(named: "GenericArrayWrapper") + let arrayFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let countFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 1) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.GenericArrayWrapper.self) + + let arrayResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + arrayFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + let countResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + countFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + // The Array field substitutes A=Double → [Double]; the Int field + // has no generic ref but should still resolve via the same path. + #expect(ObjectIdentifier(arrayResolved) == ObjectIdentifier([Double].self)) + #expect(ObjectIdentifier(countResolved) == ObjectIdentifier(Int.self)) + } + + @Test("substitutes the generic parameter inside Optional") + func substitutesIntoOptionalOfGenericParameter() throws { + _ = Fixtures.OptionalGenericFieldStruct.self + + let descriptor = try structDescriptor(named: "OptionalGenericFieldStruct") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.OptionalGenericFieldStruct.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(Bool?.self)) + } + + @Test("substitutes both parameters inside Dictionary") + func substitutesIntoDictionaryFieldOverBothParameters() throws { + _ = Fixtures.DictionaryGenericFieldStruct.self + + let descriptor = try structDescriptor(named: "DictionaryGenericFieldStruct") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try StructMetadata.createInProcess(Fixtures.DictionaryGenericFieldStruct.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + // Dictionary needs both substitutions; if the helper + // dropped or swapped either one, we'd see a different concrete type. + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier([String: Int].self)) + } + + // MARK: - Enum metadata + + @Test("works for enum metadata as well as struct metadata") + func resolvesGenericParameterInsideEnumPayload() throws { + _ = Fixtures.GenericResultEnum.self + + let descriptor = try enumDescriptor(named: "GenericResultEnum") + let payloadFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try EnumMetadata.createInProcess(Fixtures.GenericResultEnum.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + payloadFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + // Enum's first case is `success(A)` — the payload type record points + // straight at A, which the runtime must substitute to Int. + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(Int.self)) + } + + // MARK: - Class metadata + + @Test("works for class metadata (non-resilient superclass — Swift root)") + func resolvesGenericParameterInsideRootClass() throws { + _ = Fixtures.GenericContainerClass.self + + let descriptor = try classDescriptor(named: "GenericContainerClass") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try ClassMetadataObjCInterop.createInProcess(Fixtures.GenericContainerClass.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(Int.self)) + } + + @Test("class path respects positional ordering of two generic parameters") + func resolvesTwoParameterClassPositionalOrdering() throws { + _ = Fixtures.TwoParameterContainerClass.self + + let descriptor = try classDescriptor(named: "TwoParameterContainerClass") + let firstFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let secondFieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 1) + let specializedMetadata = try ClassMetadataObjCInterop.createInProcess(Fixtures.TwoParameterContainerClass.self) + + let firstResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + firstFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + let secondResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + secondFieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(firstResolved) == ObjectIdentifier(Int.self)) + #expect(ObjectIdentifier(secondResolved) == ObjectIdentifier(String.self)) + } + + @Test("class path resolves the subclass's own generic parameter") + func resolvesSubclassOwnGenericParameter() throws { + _ = Fixtures.GenericSubclass.self + + let descriptor = try classDescriptor(named: "GenericSubclass") + // Subclass declares `let childValue: B` — index 0 in *its* field list. + // Parent fields are not part of the subclass's field descriptor. + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let specializedMetadata = try ClassMetadataObjCInterop.createInProcess(Fixtures.GenericSubclass.self) + + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: specializedMetadata, + in: machO + ) + ) + // `childValue: B` → B is the second declared param → String. + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(String.self)) + } + + // MARK: - Negative paths + + @Test("non-generic field still resolves through the bare overload") + func nonGenericFieldResolvesThroughBareOverload() throws { + _ = Fixtures.NonGenericIntStruct.self + + let descriptor = try structDescriptor(named: "NonGenericIntStruct") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + + // The bare overload (no specialization context) must still work + // for fully-resolved mangled names — verifies the parameter-forwarding + // bug fix didn't break the nil-context case. + let resolvedType = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext(fieldName, in: machO) + ) + #expect(ObjectIdentifier(resolvedType) == ObjectIdentifier(Int.self)) + } + + @Test("a generic-bearing mangled name resolves to nil without specialization context") + func genericMangledNameReturnsNilWithoutContext() throws { + // Sanity-check the negative path: when we know the mangled name + // references generic params but don't supply them, the runtime + // returns nil instead of trapping. This both documents the API + // contract and pins the parameter-forwarding fix — pre-fix, the + // C parameters were dropped and the runtime received `nil` either + // way, so any call with a generic mangled name would have failed + // silently (matching this test) regardless of whether the caller + // passed the context. Combined with `resolvesSingleParameterToInt` + // — which fails pre-fix — this pair pins the regression. + _ = Fixtures.SingleParameterBox.self + let descriptor = try structDescriptor(named: "SingleParameterBox") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + + let resolvedType = try RuntimeFunctions.getTypeByMangledNameInContext(fieldName, in: machO) + #expect(resolvedType == nil) + } + + // MARK: - Integration: distinct specializations stay distinct + + @Test("two specializations of the same struct yield distinct resolved field metatypes") + func twoSpecializationsYieldDistinctFieldMetatypes() throws { + _ = Fixtures.SingleParameterBox.self + _ = Fixtures.SingleParameterBox.self + + let descriptor = try structDescriptor(named: "SingleParameterBox") + let fieldName = try fieldMangledTypeName(of: descriptor, atFieldIndex: 0) + let intMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + let stringMetadata = try StructMetadata.createInProcess(Fixtures.SingleParameterBox.self) + + let intResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: intMetadata, + in: machO + ) + ) + let stringResolved = try #require( + try RuntimeFunctions.getTypeByMangledNameInContext( + fieldName, + specializedFrom: stringMetadata, + in: machO + ) + ) + #expect(ObjectIdentifier(intResolved) == ObjectIdentifier(Int.self)) + #expect(ObjectIdentifier(stringResolved) == ObjectIdentifier(String.self)) + // Identity comparison guards against accidental metadata collapse + // if the helper ever cached on (descriptor, mangledName) without + // keying on the substitutions. + #expect(ObjectIdentifier(intResolved) != ObjectIdentifier(stringResolved)) + } +} diff --git a/Tests/SwiftInterfaceTests/GenericSpecializationTests.swift b/Tests/SwiftInterfaceTests/GenericSpecializationTests.swift index d8069a2f..aa62888e 100644 --- a/Tests/SwiftInterfaceTests/GenericSpecializationTests.swift +++ b/Tests/SwiftInterfaceTests/GenericSpecializationTests.swift @@ -15,61 +15,6 @@ import OrderedCollections @Suite(.serialized) struct GenericSpecializationTests { - /// One-shot cache of the `SwiftInterfaceIndexer` shape used across all - /// nested suites. swift-testing instantiates a fresh suite struct per - /// `@Test`; the actor lets each instance share a single prepared indexer - /// instead of paying preparation cost N × suite-count times. - fileprivate actor SharedIndexerCache { - static let shared = SharedIndexerCache() - - private var indexerCache: SwiftInterfaceIndexer? - - enum CacheError: Error, LocalizedError { - case missingImage(name: String) - - var errorDescription: String? { - switch self { - case .missingImage(let name): - return "expected MachOImage(name: \"\(name)\") to be loadable for the test fixture" - } - } - } - - /// Indexer over the current process plus Foundation and libswiftCore. - func indexer() async throws -> SwiftInterfaceIndexer { - if let indexerCache { return indexerCache } - let indexer = SwiftInterfaceIndexer(in: MachOImage.current()) - try indexer.addSubIndexer(SwiftInterfaceIndexer(in: Self.requireImage(name: "Foundation"))) - try indexer.addSubIndexer(SwiftInterfaceIndexer(in: Self.requireImage(name: "libswiftCore"))) - try await indexer.prepare() - indexerCache = indexer - return indexer - } - - private static func requireImage(name: String) throws -> MachOImage { - guard let image = MachOImage(name: name) else { - throw CacheError.missingImage(name: name) - } - return image - } - } - - /// Single shared `MachOImage.current()` reference for the nested suites. - /// `.current()` already returns the same identity each call, but caching - /// it once spares the repeated function-call overhead and makes the - /// access path symmetric with `SharedIndexerCache.shared`. - fileprivate static let sharedMachO: MachOImage = .current() - - /// Shared environment for the nested suites. Conforming suites get a - /// `machO` (sync, backed by `sharedMachO`) and an `indexer` (async, - /// backed by `SharedIndexerCache.shared.indexer()`) for free via the - /// default implementations below — no per-suite stored properties or - /// `init` are needed. - protocol Environment { - var machO: MachOImage { get } - var indexer: SwiftInterfaceIndexer { get async throws } - } - // MARK: - Fixture types // // All generic-shape fixtures live on the outer suite so that the @@ -204,6 +149,55 @@ struct GenericSpecializationTests { init(a: A) { self.a = a } } + // Fixtures used by the baseClass-requirement preflight tests. The + // class shapes mirror a tiny three-level inheritance chain so a single + // suite can pin (a) a successful direct match, (b) a successful + // multi-step superclass walk, and (c) a failed walk on an unrelated + // class, all without dragging external frameworks into the test image. + class TestRequirementBaseClass { + var baseField: Int = 0 + init() {} + } + + class TestRequirementSubClass: TestRequirementBaseClass {} + + final class TestRequirementGrandChildClass: TestRequirementSubClass {} + + final class TestRequirementUnrelatedClass {} + + /// `` — single-parameter struct used to + /// drive the baseClass requirement through both `runtimePreflight` + /// (subclass / non-subclass / non-class checks) and `specialize` + /// (the metadata accessor is happy with any class because baseClass + /// has `hasKeyArgument == false`). + struct TestBaseClassRequirementStruct { + let a: A + } + + /// Helper protocol carrying an associated type used as the RHS of the + /// sameType-requirement fixture below. Lives in this test file because + /// it is a Swift-6-language-mode workaround: every shape of sameType + /// that the language *will* accept (`A == B`, `A == Int`, + /// `B == A` even when nested) is rejected as redundant or + /// non-generic, so the fixture is forced into the only remaining + /// shape — `A == B.Element` — which keeps both sides in the same + /// generic context without making either side trivially equivalent. + protocol TestSameTypeAssocCarrier { + associatedtype Element + } + + /// ` where A == B.Element` — fixture + /// used by the sameType preflight tests. The LHS is a direct generic + /// parameter, so `collectRequirements` attaches the resulting + /// `.sameType` record to `A`'s requirement list (the side that the + /// preflight check inspects). The RHS is an associated-type access + /// path which exercises the dedicated downgrade-to-warning branch in + /// `runtimeSameTypeCheck`. + struct TestSameTypeViaAssocStruct where A == B.Element { + let a: A + let b: B + } + /// Two unrelated protocols both declaring `associatedtype Element`. /// The companion `DualElementStruct` fixture pins the Swift compiler's /// GenericSignature minimization invariant — see the matching test @@ -231,7 +225,7 @@ struct GenericSpecializationTests { // suite below. @Suite("Make Request") - struct MakeRequest: Environment { + struct MakeRequest: GenericSpecializationTestingEnvironment { @Test func basicShape() async throws { let descriptor = try structDescriptor(named: "TestGenericStruct") @@ -495,6 +489,45 @@ struct GenericSpecializationTests { "GenericSignature minimization should pick one canonical protocol for A.Element; saw \(protocolIdentities)" ) } + + /// Reproduces the field-report bug where picking `Swift.Result` + /// as a candidate in RuntimeViewer's specialization sheet failed + /// with `Demangling.DemanglingError.matchFailed(wanted: "(read test + /// function to succeed)", at: 0)` while sibling stdlib generics + /// (`Array`, `Optional`, `Dictionary`) worked. The bug is upstream + /// of any wire / IPC layer — driving `makeRequest` directly on + /// `Swift.Result`'s descriptor is enough to hit it, so this test + /// has no RuntimeViewer dependency. + /// + /// The lookup walks `allAllTypeDefinitions` (the cross-image + /// aggregate) because `Result` lives in libswiftCore, not the + /// current test image. The matched entry's `machO` is fed back + /// into a fresh specializer so we parse `Result`'s descriptor in + /// its own image — mirroring how `RuntimeSwiftSection` + /// `specializationRequest(forCandidateID:in:)` is supposed to + /// behave. + @Test func swiftResultMakeRequestSucceeds() async throws { + let aggregate = try await indexer.allAllTypeDefinitions + let entry = try #require( + aggregate.first(where: { typeName, value in + typeName.name == "Swift.Result" + && value.machO.imagePath.contains("libswiftCore") + })?.value, + "expected Swift.Result to be indexed via libswiftCore sub-indexer" + ) + let specializer = GenericSpecializer( + machO: entry.machO, + conformanceProvider: IndexerConformanceProvider(indexer: try await indexer), + indexer: try await indexer + ) + let request = try specializer.makeRequest( + for: entry.value.type.typeContextDescriptorWrapper + ) + #expect(request.parameters.count == 2, "Result has two type parameters") + let parameterNames = request.parameters.map(\.name) + #expect(parameterNames == ["A", "B"], "expected canonical (A, B) generic parameter names; got \(parameterNames)") + } + } // MARK: - Specialize @@ -507,7 +540,7 @@ struct GenericSpecializationTests { // case routing). @Suite("Specialize") - struct Specialize: Environment { + struct Specialize: GenericSpecializationTestingEnvironment { @Test func manualAccessorMatchesSpecializerWitnessOrder() async throws { let descriptor = try inProcessStructDescriptor(named: "TestGenericStruct") @@ -1006,7 +1039,7 @@ struct GenericSpecializationTests { // depth ≥ 2 plus the SwiftDump dumper and the inverted-protocol overlay. @Suite("Nested Generics") - struct NestedGenerics: Environment { + struct NestedGenerics: GenericSpecializationTestingEnvironment { @Test func twoLevelBaseline() throws { let descriptor = try structDescriptor(named: "NestedGenericTwoLevelInner") let genericContext = try #require(try descriptor.genericContext(in: machO)) @@ -1268,7 +1301,7 @@ struct GenericSpecializationTests { // so this group stays focused on argument-shape errors / warnings. @Suite("Validation") - struct Validation: Environment { + struct Validation: GenericSpecializationTestingEnvironment { @Test func reportsMissingArguments() throws { let descriptor = try structDescriptor(named: "TestGenericStruct") @@ -1421,7 +1454,7 @@ struct GenericSpecializationTests { // `witnessTableNotFound`. @Suite("Runtime Preflight") - struct RuntimePreflight: Environment { + struct RuntimePreflight: GenericSpecializationTestingEnvironment { @Test func catchesProtocolMismatch() async throws { // TestSingleProtocolStruct. Picking a Function type for // A (Functions don't conform to Hashable) must trip the preflight. @@ -1581,6 +1614,321 @@ struct GenericSpecializationTests { } } } + + // MARK: baseClass requirement coverage + // + // Pre-fix `runtimePreflight` skipped every `.baseClass` record + // (the joint `case .protocol, .sameType, .baseClass: continue` + // arm), so a struct selection or an unrelated-class selection on + // `` fell through to the metadata + // accessor — the runtime then rejected the type with a generic + // failure deep inside `swift_getGenericMetadata`. The tests below + // pin the new typed behaviour: typed errors for the bad cases, + // silent success for the good ones, plus a sanity check that the + // requirement itself is exposed by `makeRequest`. + + @Test func baseClassRequirementSurfacesInRequest() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + #expect(request.parameters.count == 1) + let parameter = request.parameters[0] + let baseClassRequirement = parameter.requirements.first { requirement in + if case .baseClass = requirement { return true } + return false + } + try #require( + baseClassRequirement != nil, + "makeRequest must surface .baseClass(...) for ``" + ) + + // baseClass is not a key argument — the metadata accessor only + // takes one slot (A's metadata) regardless of the class chain. + #expect(request.keyArgumentCount == 1) + } + + @Test func baseClassPreflightAcceptsDirectSubclass() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + let selection: SpecializationSelection = [ + "A": .metatype(GenericSpecializationTests.TestRequirementSubClass.self) + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + #expect(preflight.isValid, "direct subclass must pass baseClass preflight, got \(preflight.errors)") + #expect(preflight.errors.isEmpty) + + // End-to-end specialize must also succeed for the same selection — + // baseClass adds no key argument, so the accessor call still + // takes a single metadata. + let result = try specializer.specialize(request, with: selection) + _ = try #require(result.resolveMetadata().struct) + } + + @Test func baseClassPreflightAcceptsBaseClassItself() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // The base class trivially satisfies its own requirement — + // covers the pointer-equality short circuit before the + // superclass walk starts. + let selection: SpecializationSelection = [ + "A": .metatype(GenericSpecializationTests.TestRequirementBaseClass.self) + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + #expect(preflight.isValid, "base class must satisfy `T: BaseClass` trivially, got \(preflight.errors)") + } + + @Test func baseClassPreflightAcceptsTransitiveSubclass() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // Grandchild forces preflight to walk more than one superclass + // hop before pointer-matching the expected base. + let selection: SpecializationSelection = [ + "A": .metatype(GenericSpecializationTests.TestRequirementGrandChildClass.self) + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + #expect( + preflight.isValid, + "transitive subclass must pass baseClass preflight (multi-step superclass chain walk), got \(preflight.errors)" + ) + } + + @Test func baseClassPreflightRejectsUnrelatedClass() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + let selection: SpecializationSelection = [ + "A": .metatype(GenericSpecializationTests.TestRequirementUnrelatedClass.self) + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + #expect(!preflight.isValid, "unrelated class must fail baseClass preflight") + let hasBaseClassError = preflight.errors.contains { error in + if case .baseClassRequirementNotSatisfied(let param, let baseClass, _) = error { + return param == "A" && baseClass.contains("TestRequirementBaseClass") + } + return false + } + #expect( + hasBaseClassError, + "preflight must report .baseClassRequirementNotSatisfied for an unrelated class, got \(preflight.errors)" + ) + } + + @Test func baseClassRequirementNarrowsCandidates() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + let parameter = try #require(request.parameters.first) + + // Sanity: the baseClass requirement is on the parameter and + // its extracted TypeName resolves through the conformance + // provider to a non-empty subclass list. This isolates a + // failure to "subclass map didn't recognise the constraint + // RHS" before checking the user-facing candidate filtering. + let baseClassTypeName = try #require( + GenericSpecializer.baseClassConstraintTypeName( + in: parameter.requirements + ), + "expected baseClassConstraintTypeName to project the .baseClass requirement to a class TypeName" + ) + let subclasses = specializer.conformanceProvider.subclasses(of: baseClassTypeName) + #expect( + !subclasses.isEmpty, + "subclasses(of: \(baseClassTypeName.name)) returned empty — narrowing falls back to 'do not narrow'" + ) + + let candidateNames = Set(parameter.candidates.map { $0.typeName.currentName }) + + // Must include the base class itself plus the two known + // subclasses (the BFS over the parent → child map walks + // multiple levels). + #expect( + candidateNames.contains("TestRequirementBaseClass"), + "baseClass-narrowed candidate list must include the base class itself, got \(candidateNames)" + ) + #expect( + candidateNames.contains("TestRequirementSubClass"), + "baseClass-narrowed candidate list must include direct subclass, got \(candidateNames)" + ) + #expect( + candidateNames.contains("TestRequirementGrandChildClass"), + "baseClass-narrowed candidate list must include transitive subclass, got \(candidateNames)" + ) + + // Must NOT include unrelated classes / value types — the whole + // point of narrowing. + #expect( + !candidateNames.contains("TestRequirementUnrelatedClass"), + "baseClass-narrowed candidate list must exclude unrelated classes, got \(candidateNames)" + ) + #expect( + !candidateNames.contains("Int"), + "baseClass-narrowed candidate list must exclude value types, got \(candidateNames)" + ) + } + + @Test func baseClassPreflightRejectsValueType() async throws { + let descriptor = try structDescriptor(named: "TestBaseClassRequirementStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // Int is a struct — selectedKind is not class-like, so the + // check fails before the superclass walk even starts. + let selection: SpecializationSelection = ["A": .metatype(Int.self)] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + #expect(!preflight.isValid, "value type must fail baseClass preflight") + let hasBaseClassError = preflight.errors.contains { error in + if case .baseClassRequirementNotSatisfied(let param, _, _) = error { + return param == "A" + } + return false + } + #expect( + hasBaseClassError, + "preflight must report .baseClassRequirementNotSatisfied for a non-class type, got \(preflight.errors)" + ) + } + + // MARK: sameType requirement coverage + // + // Swift 6 rejects every shape of `where LHS == RHS` that would let + // us pin both `directGenericParamName` (GP-vs-GP) and the + // concrete-type branch in source: `A == B` is "makes equivalent", + // `A == Int` is "makes 'A' non-generic", and even nested `B == A` + // is "makes equivalent" because the inner generic context sees A + // as cumulative. The single shape that survives the language + // diagnostic is `A == B.Element`, which is what the fixture above + // uses. preflight on that shape exercises the + // associated-type-path branch — the typed downgrade to a + // `.sameTypeRequirementResolutionSkipped` warning. The other two + // branches (GP-vs-GP, GP-vs-concrete) live behind the diagnostic + // wall; they are reachable from binaries built in Swift-5 mode + // (e.g. SymbolTestsCore's `SameTypeRequirementTest`) but cannot + // be constructed inline in this test file's Swift-6 source. + + @Test func sameTypeRequirementSurfacesInRequest() async throws { + let descriptor = try structDescriptor(named: "TestSameTypeViaAssocStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // The .sameType record attaches to LHS A's requirement list + // because `collectRequirements` only keeps requirements whose + // LHS is a direct GP — A is, B.Element is not. + let parameterA = try #require( + request.parameters.first { $0.name == "A" }, + "expected parameter A in TestSameTypeViaAssocStruct" + ) + let hasSameType = parameterA.requirements.contains { requirement in + if case .sameType = requirement { return true } + return false + } + #expect( + hasSameType, + "makeRequest must surface .sameType(...) for `where A == B.Element` on parameter A, got requirements: \(parameterA.requirements)" + ) + } + + // The unified constraint check resolves both LHS and RHS through + // `swift_getTypeByMangledNameInContext` (the same routine Swift's + // own `_checkGenericRequirements` uses, + // `swift/stdlib/public/runtime/ProtocolConformance.cpp:1846`), so + // a `where A == B.Element` requirement is verified by substitution + // — not deferred to a downgrade warning. The two tests below pin + // the consistent and inconsistent shapes of that verification. + + @Test func sameTypeAssociatedPathPreflightAcceptsConsistentSelection() async throws { + let descriptor = try structDescriptor(named: "TestSameTypeViaAssocStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // A == B.Element with B.Element == Int and A == Int → consistent. + struct CarrierWithIntElement: TestSameTypeAssocCarrier { + typealias Element = Int + } + let selection: SpecializationSelection = [ + "A": .metatype(Int.self), + "B": .metatype(CarrierWithIntElement.self), + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + + let hasMismatchError = preflight.errors.contains { error in + if case .sameTypeRequirementNotSatisfied = error { return true } + return false + } + #expect( + !hasMismatchError, + "consistent `A == B.Element` selection must pass preflight, got errors: \(preflight.errors)" + ) + + // The unified path must successfully resolve B.Element via + // runtime substitution rather than downgrade to a warning — + // i.e. it actually verified the equality, didn't skip it. + let hasResolutionSkipped = preflight.warnings.contains { warning in + if case .sameTypeRequirementResolutionSkipped = warning { return true } + return false + } + #expect( + !hasResolutionSkipped, + "preflight must resolve `B.Element` via runtime substitution, not downgrade to a warning" + ) + } + + @Test func sameTypeAssociatedPathPreflightRejectsInconsistentSelection() async throws { + let descriptor = try structDescriptor(named: "TestSameTypeViaAssocStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + + // A == B.Element required, but A=String / B.Element=Int — the + // unified pass must catch this even though the LHS is a direct + // GP and RHS is an associated-type access path (the case + // pre-refactor preflight downgraded to a warning). + struct CarrierWithIntElement: TestSameTypeAssocCarrier { + typealias Element = Int + } + let selection: SpecializationSelection = [ + "A": .metatype(String.self), + "B": .metatype(CarrierWithIntElement.self), + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + + #expect(!preflight.isValid, "inconsistent `A == B.Element` selection must fail preflight") + let hasMismatchError = preflight.errors.contains { error in + if case .sameTypeRequirementNotSatisfied = error { return true } + return false + } + #expect( + hasMismatchError, + "preflight must report .sameTypeRequirementNotSatisfied for A=String / B.Element=Int, got errors: \(preflight.errors)" + ) + } } // MARK: - Invariants @@ -1594,7 +1942,7 @@ struct GenericSpecializationTests { // mis-feeds individual PWT slots. @Suite("Invariants") - struct Invariants: Environment { + struct Invariants: GenericSpecializationTestingEnvironment { // Associated-type PWT order with same-leaf interleaving. // // Regression coverage for the previously-buggy @@ -1786,7 +2134,7 @@ struct GenericSpecializationTests { // missing infrastructure, key-argument count mismatches, etc. @Suite("Error Paths") - struct ErrorPaths: Environment { + struct ErrorPaths: GenericSpecializationTestingEnvironment { // Bug reproduction #1: specialize doesn't self-check // keyArgumentCount. // @@ -1939,7 +2287,7 @@ struct GenericSpecializationTests { // public API surface independent of any single specialization run. @Suite("Models") - struct Models: Environment { + struct Models: GenericSpecializationTestingEnvironment { @Test func selectionBuilderBasic() throws { let selection = SpecializationSelection.builder() .set("A", to: [Int].self) @@ -2218,82 +2566,442 @@ struct GenericSpecializationTests { "doesType returns true if any provider says yes") } } -} -// MARK: - Default helpers for nested suites + // MARK: - Bound Generic + // + // End-to-end coverage of `Argument.boundGeneric` from + // `Roadmaps/2026-05-11-bound-generic-candidates.md`. The new case lets + // the specializer accept `Array` / `Dictionary>` + // shapes without the caller building an intermediate + // `SpecializationResult` themselves, while integrating with every + // existing validation pass: static `validate`, runtime preflight, the + // unified sameType / baseClass constraint check, and the + // `buildKeyArgumentsBuffer` PWT-ordering invariant. + + @Suite("Bound Generic") + struct BoundGeneric: GenericSpecializationTestingEnvironment { + /// Resolve the request + the Swift.Array candidate (flagged + /// `isGeneric`) used by every boundGeneric test that hosts a + /// single-parameter Hashable-constrained struct. Centralised so + /// each test reads the request once and then drives the + /// boundGeneric pipeline without re-deriving the candidate. + private func arrayHashableHost( + specializer: GenericSpecializer + ) throws -> (SpecializationRequest, SpecializationRequest.Candidate) { + let descriptor = try structDescriptor(named: "TestSingleProtocolStruct") + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + let arrayCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Array" && $0.isGeneric + }, + "expected Swift.Array candidate flagged isGeneric in TestSingleProtocolStruct's candidate list" + ) + return (request, arrayCandidate) + } -extension GenericSpecializationTests.Environment { - /// Sync access to the cached `MachOImage.current()` — every nested - /// suite shares the same instance. - var machO: MachOImage { - GenericSpecializationTests.sharedMachO - } + // Test 1: outer = Array via .boundGeneric must equal the + // canonical [Int].self metatype path. The runtime caches generic + // metadata by descriptor + arguments, so two paths landing on the + // same canonical type must produce identical metadata pointers. + @Test func arrayIntMatchesMetatypePath() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let (request, arrayCandidate) = try arrayHashableHost(specializer: specializer) - /// Async access to the prepared indexer. The actor cache builds it once - /// per process and hands every test the same reference, so the awaiter - /// pays the preparation cost zero times after the first hit. - var indexer: SwiftInterfaceIndexer { - get async throws { - try await GenericSpecializationTests.SharedIndexerCache.shared.indexer() + let boundResult = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + ]) + let metatypeResult = try specializer.specialize(request, with: [ + "A": .metatype([Int].self), + ]) + + #expect( + try boundResult.metadata() == metatypeResult.metadata(), + ".boundGeneric(Array, [Int]) must resolve to the same outer metadata pointer as .metatype([Int].self)" + ) + + let resolvedA = try #require(boundResult.argument(for: "A")) + let innerResult = try #require( + resolvedA.innerResult, + "ResolvedArgument.innerResult must be populated for .boundGeneric selections" + ) + let innerMetadata = try innerResult.metadata() + let directArrayInt = try Metadata.createInProcess([Int].self) + #expect( + innerMetadata == directArrayInt, + "inner .boundGeneric must land on the canonical Array metadata slot" + ) } - } - /// Resolves the first struct context descriptor whose name contains - /// `nameContains`. The fixtures live as nested types on the outer suite, - /// so a substring match against the mangled name is sufficient and - /// avoids pinning each test to the full module-qualified form. - func structDescriptor(named nameContains: String) throws -> StructDescriptor { - try #require( - try machO.swift.typeContextDescriptors.first { - try $0.struct?.name(in: machO).contains(nameContains) == true - }?.struct, - "expected a struct context descriptor whose name contains \"\(nameContains)\"" - ) - } + // Test 2: two-level nested .boundGeneric for + // Dictionary>. The metatype comparison pins + // that the recursion bottoms out on the same runtime cache slot + // as the directly-typed argument, and `innerResult` exposes the + // full binding tree to consumers. + @Test func nestedDictionaryStringArrayInt() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let descriptor = try structDescriptor(named: "TestSingleProtocolStruct") + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + let dictionaryCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Dictionary" && $0.isGeneric + }, + "expected Swift.Dictionary candidate flagged isGeneric" + ) + let arrayCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Array" && $0.isGeneric + }, + "expected Swift.Array candidate flagged isGeneric" + ) - /// Resolves a struct descriptor and binds it to the in-process reader via - /// `asPointerWrapper(in:)`. Required for callers that invoke the - /// no-argument overloads of descriptor methods (e.g. `genericContext()`), - /// which read through the descriptor's embedded reader rather than an - /// explicit `MachOImage` argument. - func inProcessStructDescriptor(named nameContains: String) throws -> StructDescriptor { - try structDescriptor(named: nameContains).asPointerWrapper(in: machO) - } + let boundResult = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: dictionaryCandidate, innerArguments: [ + "A": .metatype(String.self), + "B": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + ]), + ]) + let metatypeResult = try specializer.specialize(request, with: [ + "A": .metatype([String: [Int]].self), + ]) + #expect( + try boundResult.metadata() == metatypeResult.metadata(), + "two-level nested .boundGeneric must equal the metatype path" + ) + + // Walk the binding tree: outer.A → Dictionary; Dictionary.B + // → Array; Array.A → Int (no inner, .metatype). + let outerA = try #require(boundResult.argument(for: "A")) + let dictionaryResult = try #require(outerA.innerResult) + let dictionaryB = try #require(dictionaryResult.argument(for: "B")) + let arrayResult = try #require(dictionaryB.innerResult) + let arrayMetadata = try arrayResult.metadata() + let directArrayInt = try Metadata.createInProcess([Int].self) + #expect(arrayMetadata == directArrayInt) + #expect( + arrayResult.argument(for: "A")?.innerResult == nil, + ".metatype leaves should not carry an innerResult" + ) + } - /// Resolves the first enum context descriptor whose name contains - /// `nameContains`. Mirrors `structDescriptor(named:)` for enum fixtures. - func enumDescriptor(named nameContains: String) throws -> EnumDescriptor { - try #require( - try machO.swift.typeContextDescriptors.first { - try $0.enum?.name(in: machO).contains(nameContains) == true - }?.enum, - "expected an enum context descriptor whose name contains \"\(nameContains)\"" - ) - } + // Test 3: PWT slot count invariant. `buildKeyArgumentsBuffer` + // asserts `metadatas.count + witnessTables.count == request + // .keyArgumentCount`; a regression that leaks inner PWTs into + // the outer buffer would trip either the invariant or the + // metadata accessor call. + @Test func keyArgumentCountInvariant() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let (request, arrayCandidate) = try arrayHashableHost(specializer: specializer) - /// Resolves the first class context descriptor whose name contains - /// `nameContains`. Mirrors `structDescriptor(named:)` for class fixtures. - func classDescriptor(named nameContains: String) throws -> ClassDescriptor { - try #require( - try machO.swift.typeContextDescriptors.first { - try $0.class?.name(in: machO).contains(nameContains) == true - }?.class, - "expected a class context descriptor whose name contains \"\(nameContains)\"" - ) - } + // TestSingleProtocolStruct: 1 metadata slot + + // 1 PWT (Hashable). Inner Array's own PWTs are consumed + // by the inner accessor and must not bleed into the outer. + #expect(request.keyArgumentCount == 2) + + let result = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + ]) + let resolvedA = try #require(result.argument(for: "A")) + #expect( + resolvedA.witnessTables.count == 1, + "outer A: Hashable contributes exactly one outer PWT; inner Array's PWTs must not surface here" + ) + } + + // Test 4: outer preflight catches a mismatched conformance even + // when the parameter was supplied via `.boundGeneric` — the + // resolved metadata (Array) doesn't + // satisfy A: Hashable and the typed + // `.protocolRequirementNotSatisfied` error must surface + // (instead of a stringified inner error). + @Test func preflightCatchesMismatchedConformance() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let (request, arrayCandidate) = try arrayHashableHost(specializer: specializer) + + let selection: SpecializationSelection = [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(TestNonGenericStruct.self), + ]), + ] + let preflight = specializer.runtimePreflight(selection: selection, for: request) + + #expect( + !preflight.isValid, + "Array must fail outer A: Hashable preflight" + ) + let hasProtocolError = preflight.errors.contains { error in + if case .protocolRequirementNotSatisfied(_, let proto, _) = error { + // protocolName carries the module prefix + // ("Swift.Hashable") because preflight reads + // `info.protocolName.name` — accept either form. + return proto.hasSuffix("Hashable") + } + return false + } + #expect( + hasProtocolError, + "preflight must surface typed .protocolRequirementNotSatisfied('Hashable'); got errors: \(preflight.errors)" + ) + } + + // Test 5: runUnifiedConstraintCheck participates when the + // selection contains `.boundGeneric` instead of `.candidate` — + // the bail-out only triggers for naked `.candidate`. The + // resolved metadata flows through the arguments buffer so + // `swift_getTypeByMangledNameInContext` sees a fully-formed + // type for sameType / baseClass substitution. + @Test func participatesInSameTypeCheck() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let descriptor = try structDescriptor(named: "TestSameTypeViaAssocStruct") + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + let arrayCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Array" && $0.isGeneric + }, + "expected Swift.Array candidate flagged isGeneric on A's candidate list" + ) + + // Element witness pins B.Element = Array; pairs with + // A's `.boundGeneric` resolution. + struct CarrierWithArrayIntElement: TestSameTypeAssocCarrier { + typealias Element = Array + } - /// Resolves the descriptor along with its generic context. Used by - /// tests that inspect the generic header (e.g. `numKeyArguments`) in - /// addition to driving `GenericSpecializer`. - func genericStructFixture( - named nameContains: String - ) throws -> (descriptor: StructDescriptor, genericContext: GenericContext) { - let descriptor = try structDescriptor(named: nameContains) - let genericContext = try #require( - try descriptor.genericContext(in: machO), - "expected genericContext on \(nameContains)" - ) - return (descriptor, genericContext) + // Consistent: A = Array, B.Element = Array. + let consistent: SpecializationSelection = [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + "B": .metatype(CarrierWithArrayIntElement.self), + ] + let preflightConsistent = specializer.runtimePreflight(selection: consistent, for: request) + #expect( + preflightConsistent.isValid, + "A=Array, B.Element=Array must pass sameType; got errors: \(preflightConsistent.errors)" + ) + let consistentResolutionSkipped = preflightConsistent.warnings.contains { warning in + if case .sameTypeRequirementResolutionSkipped = warning { return true } + return false + } + #expect( + !consistentResolutionSkipped, + "constraint check must run on .boundGeneric, not bail out as it does for .candidate" + ) + + // Inconsistent: A = Array, B.Element = Array. + let inconsistent: SpecializationSelection = [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(String.self), + ]), + "B": .metatype(CarrierWithArrayIntElement.self), + ] + let preflightInconsistent = specializer.runtimePreflight(selection: inconsistent, for: request) + #expect(!preflightInconsistent.isValid) + let hasSameTypeError = preflightInconsistent.errors.contains { error in + if case .sameTypeRequirementNotSatisfied = error { return true } + return false + } + #expect( + hasSameTypeError, + "A=Array, B.Element=Array must fail sameType; got errors: \(preflightInconsistent.errors)" + ) + } + + // Test 6: inner failures preserve their typed identity. The + // `boundGenericInnerFailed` case wraps the inner cause without + // collapsing it to a string, so callers can pattern-match on + // the original error. The outer-driven path (preflight catches + // first) still surfaces a recognizable inner cause in the + // joined reason string. + @Test func innerFailureSurfacesTyped() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let (request, _) = try arrayHashableHost(specializer: specializer) + + // Pull a non-generic candidate (Int) — supplying it to + // `.boundGeneric` makes the inner `makeRequest` throw + // `notGenericType`, which preflight routes through + // `metadataResolutionFailed` and `specialize` surfaces as + // `specializationFailed` whose reason still mentions the + // typed cause. + let intCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Int" && !$0.isGeneric + }, + "expected Swift.Int non-generic candidate" + ) + + do { + _ = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: intCandidate, innerArguments: [:]), + ]) + Issue.record("expected specialize to throw for non-generic baseCandidate") + } catch let GenericSpecializer.SpecializerError.specializationFailed(reason) { + #expect( + reason.lowercased().contains("not generic") || + reason.contains("could not build inner request"), + "specializationFailed reason must mention the inner cause; got: \(reason)" + ) + } catch { + Issue.record("expected specializationFailed; got \(error)") + } + + // Error-shape pin: the new SpecializerError case carries + // the underlying error untouched so downstream code can + // pattern-match against the typed cause. + let directInnerCause = GenericSpecializer.SpecializerError.notGenericType( + type: TypeContextDescriptorWrapper.struct( + try structDescriptor(named: "TestNonGenericStruct") + ) + ) + let wrapped = GenericSpecializer.SpecializerError.boundGenericInnerFailed( + parameterName: "Outer", + underlying: directInnerCause + ) + guard case .boundGenericInnerFailed(let parameterName, let underlying) = wrapped else { + Issue.record("expected boundGenericInnerFailed case") + return + } + #expect(parameterName == "Outer") + let recoveredInner = try #require( + underlying as? GenericSpecializer.SpecializerError + ) + if case .notGenericType = recoveredInner { + // OK + } else { + Issue.record("underlying error lost its typed identity: \(recoveredInner)") + } + #expect(wrapped.errorDescription?.contains("Outer") == true) + } + + // Test 7: ResolvedArgument.innerResult population for every + // argument shape — `.metatype` / `.metadata` / `.candidate` → + // nil; `.boundGeneric` / `.specialized` → populated with a + // result whose metadata equals the directly-built leaf. + @Test func resolvedArgumentInnerResultPopulation() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + let (request, arrayCandidate) = try arrayHashableHost(specializer: specializer) + + // .metatype → nil + let metatypeResult = try specializer.specialize(request, with: [ + "A": .metatype(Int.self), + ]) + #expect( + metatypeResult.argument(for: "A")?.innerResult == nil, + "innerResult must be nil for .metatype selections" + ) + + // .candidate → also nil. Even though the candidate's + // resolution involves a metadata accessor call, the + // contract is that `innerResult` is only populated for + // the wrapped-result entry points (`.specialized` / + // `.boundGeneric`). Non-generic candidates have no inner + // request, so the tree terminates here. + let intCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Int" && !$0.isGeneric + } + ) + let candidateResult = try specializer.specialize(request, with: [ + "A": .candidate(intCandidate), + ]) + #expect( + candidateResult.argument(for: "A")?.innerResult == nil, + "innerResult must be nil for non-generic .candidate selections" + ) + + // .boundGeneric → populated + let boundResult = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + ]) + let argBound = try #require(boundResult.argument(for: "A")) + let boundInner = try #require(argBound.innerResult) + #expect( + try boundInner.metadata() == Metadata.createInProcess([Int].self), + "innerResult for .boundGeneric must capture the recursively-resolved Array metadata" + ) + + // .specialized → also populated. Uses an unconstrained + // host fixture so the inner result (also unconstrained) + // doesn't have to satisfy A: Hashable; the inner-tree + // pinning is independent of the outer's constraints. + let unconstrainedDescriptor = try structDescriptor(named: "TestUnconstrainedStruct") + let unconstrainedRequest = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(unconstrainedDescriptor) + ) + let innerSpecialized = try specializer.specialize( + unconstrainedRequest, with: ["A": .metatype(Int.self)] + ) + let specializedResult = try specializer.specialize( + unconstrainedRequest, with: ["A": .specialized(innerSpecialized)] + ) + let argSpecialized = try #require(specializedResult.argument(for: "A")) + let recoveredInner = try #require(argSpecialized.innerResult) + #expect( + try recoveredInner.metadata() == innerSpecialized.metadata(), + "innerResult for .specialized must reference the supplied result" + ) + } + + // Test 8: `maxBindingDepth` boundary guard. The soft guard at + // `internalSpecialize`, `internalValidate`, and + // `collectBoundGenericValidation` should refuse a binding chain + // whose depth meets or exceeds the configured ceiling, surfaced + // as a typed `specializationFailed` with a recognizable reason. + // Driving this with the default `16` ceiling is awkward; we + // tighten the knob to `1` and feed a two-level + // `Dictionary>` tree so the boundary fires + // at the inner `.boundGeneric(Array, ...)` recursion. + @Test func maxBindingDepthGuardTrips() async throws { + let specializer = GenericSpecializer(indexer: try await indexer) + specializer.maxBindingDepth = 1 + + let descriptor = try structDescriptor(named: "TestSingleProtocolStruct") + let request = try specializer.makeRequest( + for: TypeContextDescriptorWrapper.struct(descriptor) + ) + let dictionaryCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Dictionary" && $0.isGeneric + } + ) + let arrayCandidate = try #require( + request.parameters[0].candidates.first { + $0.typeName.currentName == "Array" && $0.isGeneric + } + ) + + do { + _ = try specializer.specialize(request, with: [ + "A": .boundGeneric(baseCandidate: dictionaryCandidate, innerArguments: [ + "A": .metatype(String.self), + "B": .boundGeneric(baseCandidate: arrayCandidate, innerArguments: [ + "A": .metatype(Int.self), + ]), + ]), + ]) + Issue.record("expected specialize to throw when binding depth exceeds maxBindingDepth") + } catch let GenericSpecializer.SpecializerError.specializationFailed(reason) { + #expect( + reason.contains("binding depth exceeded"), + "specializationFailed reason must mention the depth guard; got: \(reason)" + ) + } catch { + Issue.record("expected specializationFailed; got \(error)") + } + } } } diff --git a/Tests/SwiftInterfaceTests/GenericTypeNameSubstitutionTests.swift b/Tests/SwiftInterfaceTests/GenericTypeNameSubstitutionTests.swift new file mode 100644 index 00000000..894b97df --- /dev/null +++ b/Tests/SwiftInterfaceTests/GenericTypeNameSubstitutionTests.swift @@ -0,0 +1,352 @@ +import Foundation +import Testing +import MachOKit +import Demangling +@_spi(Internals) import MachOSymbols +@_spi(Support) @testable import SwiftInterface +@testable import MachOSwiftSection +@testable import MachOTestingSupport +@_spi(Internals) import SwiftInspection + +// MARK: - Pure helper unit tests (no fixture) + +/// Shape-only unit tests for `TypeDefinition.boundGenericTypeName`. Don't use +/// the production specialize() path so they're fast and don't depend on the +/// indexer / shared MachO image. End-to-end behavior — including +/// mangle/demangle round-trip on real Swift types — lives in the second suite +/// below. +@Suite("TypeDefinition.boundGenericTypeName (helper shape)") +struct GenericTypeNameSubstitutionHelperTests { + private func makeStructureTypeNode(module: String = "TestModule", name: String) -> Node { + let moduleNode = Node.create(kind: .module, contents: .text(module)) + let identifierNode = Node.create(kind: .identifier, contents: .text(name)) + let structureNode = Node.create(kind: .structure, children: [moduleNode, identifierNode]) + return Node.create(kind: .type, children: [structureNode]) + } + + private func makeBareStructureNode(module: String = "TestModule", name: String) -> Node { + let moduleNode = Node.create(kind: .module, contents: .text(module)) + let identifierNode = Node.create(kind: .identifier, contents: .text(name)) + return Node.create(kind: .structure, children: [moduleNode, identifierNode]) + } + + @Test("struct kind produces boundGenericStructure node") + func structKindWraps() throws { + let unbound = TypeName(node: makeStructureTypeNode(name: "Box"), kind: .struct) + let argument = makeStructureTypeNode(name: "Int") + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [argument] + ) + + #expect(result.kind == .struct) + #expect(result.node.kind == .type) + + let firstChild = try #require(result.node.firstChild) + #expect(firstChild.kind == .boundGenericStructure) + #expect(firstChild.children.count == 2) + #expect(firstChild.children[0].kind == .type) + #expect(firstChild.children[1].kind == .typeList) + } + + @Test("class kind produces boundGenericClass node") + func classKindWraps() throws { + let moduleNode = Node.create(kind: .module, contents: .text("TestModule")) + let identifierNode = Node.create(kind: .identifier, contents: .text("Container")) + let classNode = Node.create(kind: .class, children: [moduleNode, identifierNode]) + let unbound = TypeName( + node: Node.create(kind: .type, children: [classNode]), + kind: .class + ) + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [makeStructureTypeNode(name: "String")] + ) + + let firstChild = try #require(result.node.firstChild) + #expect(firstChild.kind == .boundGenericClass) + #expect(result.kind == .class) + } + + @Test("enum kind produces boundGenericEnum node") + func enumKindWraps() throws { + let moduleNode = Node.create(kind: .module, contents: .text("TestModule")) + let identifierNode = Node.create(kind: .identifier, contents: .text("Either")) + let enumNode = Node.create(kind: .enum, children: [moduleNode, identifierNode]) + let unbound = TypeName( + node: Node.create(kind: .type, children: [enumNode]), + kind: .enum + ) + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [ + makeStructureTypeNode(name: "Int"), + makeStructureTypeNode(name: "String"), + ] + ) + + let firstChild = try #require(result.node.firstChild) + #expect(firstChild.kind == .boundGenericEnum) + #expect(result.kind == .enum) + } + + @Test("typeList contains every argument in order") + func typeListPositionalOrder() throws { + let unbound = TypeName(node: makeStructureTypeNode(name: "Triple"), kind: .struct) + let argA = makeStructureTypeNode(name: "Int") + let argB = makeStructureTypeNode(name: "String") + let argC = makeStructureTypeNode(name: "Bool") + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [argA, argB, argC] + ) + + let typeList = try #require(result.node.firstChild?.children[1]) + #expect(typeList.kind == .typeList) + #expect(typeList.children.count == 3) + for child in typeList.children { + #expect(child.kind == .type) + } + } + + @Test("bare structure unbound (no .type wrap) is auto-wrapped") + func unboundAutoWrap() throws { + let bareUnbound = makeBareStructureNode(name: "Box") + let unbound = TypeName(node: bareUnbound, kind: .struct) + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [makeStructureTypeNode(name: "Int")] + ) + + let firstChild = try #require(result.node.firstChild) + let unboundChild = firstChild.children[0] + #expect(unboundChild.kind == .type) + let inner = try #require(unboundChild.firstChild) + #expect(inner.kind == .structure) + } + + @Test("bare structure argument (no .type wrap) is auto-wrapped") + func argumentAutoWrap() throws { + let unbound = TypeName(node: makeStructureTypeNode(name: "Box"), kind: .struct) + let bareArgument = makeBareStructureNode(name: "Int") + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [bareArgument] + ) + + let typeList = try #require(result.node.firstChild?.children[1]) + let firstArgument = typeList.children[0] + #expect(firstArgument.kind == .type) + let inner = try #require(firstArgument.firstChild) + #expect(inner.kind == .structure) + } + + @Test(".type-wrapped input is not double-wrapped") + func noDoubleWrap() throws { + let unboundTypeNode = makeStructureTypeNode(name: "Box") + let unbound = TypeName(node: unboundTypeNode, kind: .struct) + let argumentTypeNode = makeStructureTypeNode(name: "Int") + + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [argumentTypeNode] + ) + + let firstChild = try #require(result.node.firstChild) + // Identity check: helper reuses the original `.type`-wrapped node + // rather than wrapping it again into `Type → Type → Structure`. + #expect(firstChild.children[0] === unboundTypeNode) + let typeList = firstChild.children[1] + #expect(typeList.children[0] === argumentTypeNode) + } + + @Test("empty argument list still produces a structurally valid tree") + func emptyArgumentList() throws { + let unbound = TypeName(node: makeStructureTypeNode(name: "Box"), kind: .struct) + let result = TypeDefinition.boundGenericTypeName( + unboundTypeName: unbound, + typeArgumentNodes: [] + ) + + let firstChild = try #require(result.node.firstChild) + #expect(firstChild.kind == .boundGenericStructure) + let typeList = firstChild.children[1] + #expect(typeList.kind == .typeList) + #expect(typeList.children.isEmpty) + } +} + +// MARK: - End-to-end fixture-driven tests +// +// Drives the full path: real fixture descriptor → GenericSpecializer → +// TypeDefinition.specialize(with:typeArgumentNodes:in:) → mangle/demangle +// round-trip on the substituted typeName. +// +// Reuses fixtures already declared inside `GenericSpecializationTests` — +// `TestUnconstrainedStruct`, `TestRefClass`, `TestClassConstraintStruct`, +// `TestDualAssociatedStruct`. Those types are guaranteed to be present in the +// test binary (the `GenericSpecializationTests.Specialize` suite already +// drives them through `specializer.specialize(...)` end-to-end), so +// `structDescriptor(named:)` reliably resolves them. +@Suite(.serialized) +struct GenericTypeNameSubstitutionEndToEndTests: GenericSpecializationTestingEnvironment { + + /// Builds a `Type → Structure(Module(Swift), Identifier(name))` node + /// without going through the demangler — keeps the test independent of + /// `demangleAsNode` symbol-mangling conventions. + private func makeSwiftStdLibTypeNode(name: String) -> Node { + let moduleNode = Node.create(kind: .module, contents: .text("Swift")) + let identifierNode = Node.create(kind: .identifier, contents: .text(name)) + let structureNode = Node.create(kind: .structure, children: [moduleNode, identifierNode]) + return Node.create(kind: .type, children: [structureNode]) + } + + /// Resolve a base `TypeDefinition` for the named fixture by walking the + /// indexer's already-prepared dictionary. + /// + /// Production flow (`RuntimeSwiftSection.specialize(for:with:)`) goes + /// through this same path: the engine never re-instantiates a + /// `TypeDefinition` from a raw descriptor — it always finds the one the + /// indexer already produced. Constructing a fresh `TypeDefinition` from a + /// raw descriptor here was crashing inside `MetadataReader.demangleContext` + /// because the file-form descriptors from `machO.swift.typeContextDescriptors` + /// require additional in-process context the test wasn't supplying. + private func resolveTypeDefinition(named substring: String) async throws -> TypeDefinition { + let resolvedIndexer = try await indexer + return try #require( + resolvedIndexer.allTypeDefinitions.first(where: { entry in + entry.key.name.contains(substring) + })?.value, + "expected indexer to have a TypeDefinition whose typeName contains \"\(substring)\"" + ) + } + + @Test("specialize substitutes the typeName into a BoundGenericStructure") + func substitutesStructTypeName() async throws { + let baseDefinition = try await resolveTypeDefinition(named: "TestUnconstrainedStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest(for: baseDefinition.type.typeContextDescriptorWrapper) + let result = try specializer.specialize(request, with: ["A": .metatype(Int.self)]) + + let intTypeNode = makeSwiftStdLibTypeNode(name: "Int") + + let specialized = try await baseDefinition.specialize( + with: result, + typeArgumentNodes: [intTypeNode], + in: machO + ) + + // Top-level shape: `Type → BoundGenericStructure(...)`. + #expect(specialized.typeName.kind == .struct) + #expect(specialized.typeName.node.kind == .type) + let firstChild = try #require(specialized.typeName.node.firstChild) + #expect(firstChild.kind == .boundGenericStructure) + #expect(firstChild.children.count == 2) + #expect(firstChild.children[1].kind == .typeList) + #expect(firstChild.children[1].children.count == 1) + } + + @Test("specialized typeName mangles successfully and demangles back to BoundGenericStructure") + func mangleDemangleRoundTrip() async throws { + let baseDefinition = try await resolveTypeDefinition(named: "TestDualAssociatedStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest(for: baseDefinition.type.typeContextDescriptorWrapper) + let result = try specializer.specialize(request, with: [ + "A": .metatype([Int].self), + "B": .metatype([String].self), + ]) + + let intTypeNode = makeSwiftStdLibTypeNode(name: "Int") + let stringTypeNode = makeSwiftStdLibTypeNode(name: "String") + + let specialized = try await baseDefinition.specialize( + with: result, + typeArgumentNodes: [intTypeNode, stringTypeNode], + in: machO + ) + + // `RuntimeSwiftSection.makeRuntimeObject` calls `mangleAsString` on + // every specialized child to derive a unique `RuntimeObject.name`. + // If the substituted typeName produced an invalid Node tree, this + // call would throw and the sidebar would silently lose the + // specialization. + let mangled = try await mangleAsString(specialized.typeName.node) + #expect(!mangled.isEmpty) + + // Round-trip: the demangler must accept the mangler's output and + // recover a `boundGenericStructure` shape with two type arguments + // in the original positional order. + // + // `mangleAsString` produces a *type-mangled* body with no global + // symbol prefix (`$s…` / `_T…`). Pass `isType: true` so the demangler + // skips the symbol-prefix check and parses the body directly as a + // type — mirrors what `MetadataReader.demangleType(for:)` does + // internally when handed a `MangledName`. + let reconstructed = try await demangleAsNode(mangled, isType: true) + let reconstructedBound = try #require(reconstructed.first(of: .boundGenericStructure)) + #expect(reconstructedBound.children.count == 2) + let reconstructedTypeList = reconstructedBound.children[1] + #expect(reconstructedTypeList.kind == .typeList) + #expect(reconstructedTypeList.children.count == 2) + } + + @Test("nil typeArgumentNodes leaves typeName at the unbound form") + func nilSubstitutionPreservesUnboundTypeName() async throws { + let baseDefinition = try await resolveTypeDefinition(named: "TestUnconstrainedStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest(for: baseDefinition.type.typeContextDescriptorWrapper) + let result = try specializer.specialize(request, with: ["A": .metatype(Int.self)]) + + // Default parameter: typeArgumentNodes is nil — preserves backward + // compatibility with the pre-substitution behavior. + let specialized = try await baseDefinition.specialize( + with: result, + in: machO + ) + + let firstChild = try #require(specialized.typeName.node.firstChild) + #expect(firstChild.kind != .boundGenericStructure) + #expect(firstChild.kind == .structure) + } + + @Test("two specializations of the same generic produce distinct mangled names") + func uniqueMangledNamesPerSpecialization() async throws { + let baseDefinition = try await resolveTypeDefinition(named: "TestUnconstrainedStruct") + let specializer = GenericSpecializer(indexer: try await indexer) + let request = try specializer.makeRequest(for: baseDefinition.type.typeContextDescriptorWrapper) + + let intResult = try specializer.specialize(request, with: ["A": .metatype(Int.self)]) + let stringResult = try specializer.specialize(request, with: ["A": .metatype(String.self)]) + + let intTypeNode = makeSwiftStdLibTypeNode(name: "Int") + let stringTypeNode = makeSwiftStdLibTypeNode(name: "String") + + let specializedInt = try await baseDefinition.specialize( + with: intResult, + typeArgumentNodes: [intTypeNode], + in: machO + ) + let specializedString = try await baseDefinition.specialize( + with: stringResult, + typeArgumentNodes: [stringTypeNode], + in: machO + ) + + let mangledInt = try await mangleAsString(specializedInt.typeName.node) + let mangledString = try await mangleAsString(specializedString.typeName.node) + + // The whole point of the typeName substitution is that every + // specialization gets a unique mangled name — that is what makes + // `RuntimeObject.name` (built from this string) distinguish + // `Box` from `Box` in the sidebar. + #expect(mangledInt != mangledString) + #expect(!mangledInt.isEmpty) + #expect(!mangledString.isEmpty) + } +}