diff --git a/Documentations/Evolutions/0007-objc-relationship-index-returns-to-application.md b/Documentations/Evolutions/0007-objc-relationship-index-returns-to-application.md new file mode 100644 index 00000000..38c31c93 --- /dev/null +++ b/Documentations/Evolutions/0007-objc-relationship-index-returns-to-application.md @@ -0,0 +1,506 @@ +# 0007 - ObjC 关系索引归还应用侧 + +- **状态**: In Progress(代码完成、测试通过;合入 main 阻塞于 MachOSwiftSection 的 pin,见「落地记录」) +- **作者**: JH +- **日期**: 2026-08-10 +- **关联提案**: MachOObjCSection [0003 - ObjC 关系反向表移出索引层,归还应用](https://github.com/MxIris-Reverse-Engineering/MachOObjCSection/blob/main/Documentations/Evolutions/0003-objc-relationship-tables-return-to-application.md) + —— **本提案是它的下游适配**,库侧的设计论证不在此重复 +- **实现分支 / PR**: 待定(依赖 MachOObjCSection `0.8.103` 发版) + +## 摘要 + +MachOObjCSection 0003 把 ObjC 的继承 / 协议遵守反向表移出 `ObjCIndexing`,库改为只通过 +`ObjCIndexingEvent` 广播发现、不再自己存表。本提案是 RuntimeViewer 侧的对应改动: + +1. 新增 `RuntimeObjCRelationshipIndex`,在 `prepare()` 期间由事件流填充,取代原先直接查库的两个方法; +2. **把事件 handler 的安装与进度转发解耦** —— 这是本提案真正的风险点,不改会让绝大多数 image + 的关系数据静默变空; +3. 删除 `RuntimeObjCSectionFactory.objcInterfaceIndexer` 这个只写不读的聚合。 + +对用户没有任何可见变化:Relationships 标签页的内容与顺序保持逐条等价。 + +## 动机 + +### 一、库不再存表,应用必须自己存 + +0003 之后 `ObjCInterfaceIndexer` 不再有 `subclasses(of:)` / `conformingClasses(toProtocol:)`, +`ObjCClassReference` 类型也一并移除。`RuntimeRelationshipsResolver.swift:85` 与 `:118` 附近对 +ObjC 关系的查询失去后端,必须在应用侧重建。 + +方向与取舍(为什么是事件流而不是重新走查、为什么 `isSwiftStable` 进 payload)已在 0003 +论证完毕,本提案直接采用其结论,不重复。 + +### 二、事件 handler 现在是**条件安装**的,照搬会静默丢掉几乎全部关系数据 + +这是本提案存在的主要理由,也是唯一一处不改就出错的地方。 + +`RuntimeObjCSection.makeEventHandler(forwardingTo:)` 目前在没有进度流时直接返回 `nil`: + +```swift +private static func makeEventHandler( + forwardingTo progressContinuation: LoadingEventContinuation? +) -> (@Sendable (ObjCIndexingEvent) -> Void)? { + guard let progressContinuation else { return nil } // ← 没有进度流就完全不装 handler + ... +} +``` + +今天这没有任何后果 —— 库无条件建表,handler 只负责转发进度。0003 之后,**没装 handler 就等于 +放弃关系数据**。 + +而实际的调用点分布是压倒性的:创建 ObjC section 的 7 处调用里,只有 1 处传了 +`progressContinuation`。 + +| 调用点 | 传 `progressContinuation`? | +|---|---| +| `RuntimeEngine.swift:883` | ✅ 唯一一处 | +| `RuntimeEngine+BackgroundIndexing.swift:62` | ❌ | +| `RuntimeEngine.swift:534` | ❌ | +| `RuntimeEngine.swift:810` | ❌ | +| `RuntimeEngine.swift:572 / 574 / 626 / 628`(按名字查的四处) | ❌ | + +其中 `RuntimeEngine+BackgroundIndexing.swift:62` 是**后台索引**的路径 —— 绝大多数 image 是被 +它带进来的。所以直接照搬现有 handler 的结果不是「某个冷门路径缺数据」,而是「只有用户手动触发 +那一次带进度条的加载有关系数据,其余全空」,且**完全静默**:不报错,Relationships 标签页看起来 +就像"这个类确实没有子类"。 + +结论:handler 的安装必须与 progressContinuation 解绑。 + +### 三、工厂聚合是只写不读的死重,一并删除 + +`RuntimeObjCSectionFactory` 持有一个聚合 `ObjCInterfaceIndexer` 并在每次建 section 时 +`addSubIndexer` 注册,但全仓库对它的引用只有四处 —— 声明(`RuntimeObjCSection.swift:379`)、 +构造(`:383`)、两处 `addSubIndexer`(`:411`、`:432`),**没有任何一处读它**。真正的跨 image +合并是 `RuntimeRelationshipsResolver` 在调用点自己做的:遍历已索引 image 路径,逐个取该 image +自己的 indexer 查表。 + +0003 删掉库侧的 `addSubIndexer(_:)` 后这个聚合无法再构造,正好一并删除。附带收益是那个 +「拿 `MachOImage.current()` 当占位」的构造 hack(`:381-384`)随之消失。 + +**Swift 侧不受影响**:`RuntimeSwiftSectionFactory.indexer` 是真被读的(`GenericSpecializer` / +`IndexerConformanceProvider` 走 `factory.indexer.upstream`,泛型特化还查 `allAllTypeDefinitions`), +是承重结构,本提案不碰。 + +### 四、它解掉一个已埋下的跨分支冲突 + +feeder 分支 `feature/node-store-adoption`(同时在 `next` 上)的 commit `f41648a` 修的是聚合造成的 +内存泄漏 —— `addSubIndexer` 没有逆操作,聚合活得和 engine 一样长,浏览过的每个 image 的索引状态 +永远释放不掉。它的修法是给本地的 `RuntimeObjCInterfaceIndexer` 加 `removeSubIndexer(_:)`,并在 +`removeSection` / `removeAllSections` 里先摘再删。 + +但 main 上的 `889f1bd` 已经把 `RuntimeObjCInterfaceIndexer` 整个删了,顶替它的库类型没有 +`removeSubIndexer`。两条线迟早要撞。 + +本提案把聚合整个删掉,泄漏源消失,逆操作不再需要 —— 冲突以「删除」的方式解决,而不是回头给库补 +一个即将过时的 API。 + +> **合并提示**:`feature/node-store-adoption` 与 main 合并时,`RuntimeObjCSectionFactory` +> 的 `removeSection` / `removeAllSections` 里那两行 `objcInterfaceIndexer.removeSubIndexer(...)` +> 应当**随聚合一并删除**,不要试图移植到库类型上。Swift 侧那一半(`RuntimeSwiftSectionFactory`) +> 不受影响,正常保留。 + +## 提议方案 + +### 一、`RuntimeObjCRelationshipIndex`:应用侧的关系索引 + +新增一个 per-image 的关系索引,职责单一:接收 `ObjCIndexingEvent` 的三个关系事件,攒成两张表, +并提供原先由库提供的两个查询。 + +- 位置:`RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/` + (放在 Relationships 而非 Indexing —— 它服务的是关系解析,不是接口解析) +- 与 `ObjCInterfaceIndexer` 一一对应:每个 `RuntimeObjCSection` 持有自己那一份 +- 同时新增 `RuntimeObjCClassReference`,字段与原库类型逐字相同(`className` / `imagePath` / + `isSwiftStable`),因为 `RuntimeRelationshipsResolver.materializeRelationshipReference(_:)` + 已经按这三个字段消费 + +### 二、handler 解耦:永远安装,进度转发才看 continuation + +`makeEventHandler` 改为**总是**返回 handler。handler 内部分流:关系事件喂给 +`RuntimeObjCRelationshipIndex`,`.progress` 事件仅在 continuation 存在时转发。 + +### 三、删除工厂聚合 + +删除 `RuntimeObjCSectionFactory.objcInterfaceIndexer` 的声明、构造与两处 `addSubIndexer` 调用。 + +### 非目标 + +- **不改 Relationships 的任何用户可见行为。** 顺序、内容、去重规则逐条等价。 +- **不改 category 的 `imagePath` 语义。** 库侧 0003 已说明:category 产生的引用里 `className` + 与 `imagePath` 不属于同一个 image(`imagePath` 是 category 所在 image)。这是既有行为, + 原样保留;想改属行为变更,另开提案。 +- **不动 Swift 侧。** `RuntimeSwiftInterfaceIndexer` 及其三张表保持现状。 +- **不改 `RuntimeRelationshipsResolver` 的跨 image 扇出结构。** 它在调用点遍历 image 的做法不变, + 只把查询对象从 `objcSection.objcIndexer` 换成 `objcSection.objcRelationshipIndex`。 +- **不引入新的关系查询能力**(如反向的「某类的所有超类」)。等价迁移,不夹带。 +- **不"顺手"修同类双 `isSwiftStable` 的重复条目。** 见「详细设计」等价性细节第 3 条 —— + 那是现有行为,修它属于行为变更,会让等价性测试失败。 + +## 详细设计 + +### `RuntimeObjCRelationshipIndex` + +```swift +/// An Objective-C class or bridged Swift class discovered to subclass another +/// class or to adopt a protocol. +/// +/// `isSwiftStable` carries the structural signal (`class_t`'s +/// `FAST_IS_SWIFT_STABLE` bit) that lets `RuntimeRelationshipsResolver` decide +/// whether to materialize the reference as a Swift `RuntimeObject` or an +/// Objective-C one. The library reports the bit; the domain decision is made here. +struct RuntimeObjCClassReference: Hashable, Sendable { + let className: String + let imagePath: String + let isSwiftStable: Bool +} + +/// Per-image Objective-C relationship index, populated from the +/// `ObjCIndexingEvent` stream that `ObjCInterfaceIndexer.prepare()` emits. +/// +/// MachOObjCSection 0003 removed the library-side reverse tables; the library +/// now only broadcasts what it discovers. This type is where those broadcasts +/// become the tables that back the Inspector's Relationships pane. +/// +/// An instance is handed to the indexer as its event handler *before* +/// `prepare()` runs and accumulates throughout the walk. The tables are built +/// on first query; `prewarm()` merely pays that cost up front and is never +/// required for correctness. +final class RuntimeObjCRelationshipIndex: @unchecked Sendable { + func record(_ event: ObjCIndexingEvent) + + /// Optional: build the tables now instead of on first query. Never required + /// — skipping it, or failing to reach it because `prepare()` threw, costs + /// nothing but the deferred build. + func prewarm() + + func subclasses(of className: String) -> [RuntimeObjCClassReference] + func conformingClasses(toProtocol protocolName: String) -> [RuntimeObjCClassReference] +} +``` + +`RuntimeObjCClassReference` 不带 `Codable`,也不是 `public`:全仓库对原库类型的引用只有两处 +(`RuntimeRelationshipsResolver` 的一处 doc comment 和一个 `private` 方法签名),关系引用 +从不跨进程 —— 越过 XPC 边界的是已经物化好的 `RuntimeObject`。搬迁正是收紧这类多余 +conformance 的窗口。 + +**建表时机:惰性,不是外部契约。** 查询发现尚未建表就先建。**不采用「`prepare()` 之后必须调用 +`freeze()`」的写法** —— 那会新造两条静默失效路径,而它们恰恰是本提案动机第二条要消灭的那一类: +忘了调 → 查询返回空、不报错;`prepare()` 是 `async throws`,抛错就跳过了调用点(除非包 `defer`), +该 section 的索引从此永远空着。惰性构建把这两条路径都变成不可能,代价只是查询路径上一次布尔检查。 + +**累积策略:单一队列,一次 `append`。** `record(_:)` 在锁下向**同一个**待处理队列追加,建表时 +按队列顺序回放。单队列不只是为了锁内工作量小,更是行为等价的前提 —— 见下节。 + +> 锁保留。当前库侧走查是单线程的(`ObjCInterfaceIndexer` 内无 `TaskGroup` / +> `concurrentPerform` / `DispatchQueue` / `async let`),所以竞争接近于零、锁的成本可忽略; +> 但**这是当前实现的事实,不是库的承诺** —— 0003 的契约四明确不承诺 `eventHandler` 的执行 +> 上下文,handler 声明为 `@Sendable` 正是为 0002 之后按 image / section 并行走查留的余地。 +> 因此锁是必需的,不能以"反正是单线程"为由去掉。 + +### 与库侧原实现的三处等价性细节 + +「顺序、内容、去重规则逐条等价」不是一句口号,落到实现上有三条必须照做: + +1. **两个 conformance 事件写进同一张表。** 库侧 `indexClass` 的 inline 采纳与 `indexCategory` + 的 category 采纳写的是**同一个** `_conformingClassesByProtocolName`,一次 + `conformingClasses(toProtocol:)` 同时返回两者。不得按事件 case 分成两张表。 +2. **顺序是「inline 整体在前,category 整体在后」,不是交错。** 库侧 `prepare()` 先走完整个 + class 列表(`:272`)再走 category 列表(`:350`),所以同一协议的 conformer 里 inline 采纳 + 的类整体排在 category 贡献的之前。按到达顺序回放单一队列天然保持这个顺序;一旦按 case 分组, + 合并时无论怎么拼都不等价。这条顺序由 0003 的**契约五**承诺(同一 image、同一库版本, + 两次 `prepare()` 事件序列完全相同;class 阶段整体先于 category 阶段),改动它算库的破坏性 + 变更 —— 因此本提案可以放心依赖它,而不是依赖一个碰巧成立的实现细节。 +3. **去重按全部三个字段,保留首次插入位置 —— 不要"顺手"改成按 `className` 去重。** + 同一个类可能同时通过 inline 与 category 采纳同一协议,而两条路径的 `isSwiftStable` 来源不同: + inline 读类自己 `class_t` 上的 flag,category 读 `objcCategory.class(in: machO)` 跨 image + 解析的结果,**解析失败兜底 `false`**。因此在「解析失败 + 该类是 Swift 类 + 同时 inline 与 + category 采纳同一协议」的组合下,会出现 `className` 相同而 `isSwiftStable` 不同的两条引用, + `OrderedSet` 不去重,Relationships 里该类出现两次、一次标 Swift 一次标 ObjC。 + + 场景罕见(category 的 target 通常在别的 image,那种情况下 inline 路径根本不会在本 indexer + 产生条目),但它是**现有行为**。按 `className` 去重、或在冲突时合并 `isSwiftStable`, + 看起来像修 bug,实际是行为变更,会让等价性测试失败。真要修另开提案。 + +### handler 安装 + +```swift +private static func makeEventHandler( + relationshipIndex: RuntimeObjCRelationshipIndex, + forwardingTo progressContinuation: LoadingEventContinuation? +) -> @Sendable (ObjCIndexingEvent) -> Void { + { event in + // Relationship events always build the index — this must NOT depend on + // whether a progress stream exists. Only one of the seven section-creation + // call sites passes a continuation; gating the handler on it would leave + // every background-indexed image without relationship data, silently. + relationshipIndex.record(event) + + guard let progressContinuation, + case .progress(let phase, let itemDescription, let currentCount, let totalCount) = event + else { return } + + progressContinuation.yield(...) + } +} +``` + +两个 `init` 都改为:先构造 `relationshipIndex`,传入 handler,再 `await objcIndexer.prepare()`。 +**`prepare()` 之后无需任何收尾调用** —— 建表是惰性的,`prepare()` 抛错也不会让索引卡在半成品状态。 + +### 调用点改动 + +`RuntimeRelationshipsResolver` 里两处,查询对象替换,其余结构不动: + +```swift +// 改前 +for reference in objcSection.objcIndexer.subclasses(of: objcKey) { ... } +for reference in objcSection.objcIndexer.conformingClasses(toProtocol: object.name) { ... } + +// 改后 +for reference in objcSection.objcRelationshipIndex.subclasses(of: objcKey) { ... } +for reference in objcSection.objcRelationshipIndex.conformingClasses(toProtocol: object.name) { ... } +``` + +`materializeRelationshipReference(_:)` 的参数类型从 `ObjCClassReference` 改为 +`RuntimeObjCClassReference`,函数体不变(三个字段同名同义)。 + +## 影响(App 型) + +- **用户可见变化**:无。Relationships 标签页的内容、顺序、去重行为逐条等价,这是本提案的硬性约束 + (见「验收标准」)。 +- **可发现性**:无新增入口、无设置项、无菜单变化。 +- **数据与配置兼容**:不涉及持久化格式,无迁移。 +- **平台与最低版本**:不变。 +- **发布影响**:需要 MachOObjCSection `0.8.103`。适配代码与 `exact: "0.8.102"` → `"0.8.103"` + 的 pin bump **同批次**提交;在库发版前本改动不能进 main。 +- **内存**:预期小幅下降 —— 聚合删除后,`subIndexers` 不再为 engine 生命周期钉住每个 image 的 + 索引状态(这正是 `f41648a` 想解决的问题)。具体数值不作承诺,落地后按需实测。 + +## 验收标准 + +1. Relationships 标签页对同一目标的结果,与改动前**逐条等价**(内容、顺序均一致)。 +2. **不带 `progressContinuation` 创建的 section 同样能查到关系数据** —— 对应动机第二条。 +3. 后台索引带进来的 image,其关系查询与手动加载的 image 无差别。 +4. `RuntimeObjCSectionFactory` 中不再存在聚合 indexer。 +5. `RuntimeViewerCore` 与 `RuntimeViewerPackages` 编译通过,测试退出码为 0。 + +## 测试策略 + +### 基线快照走公开 API,仍须在升级依赖之前采集 + +**落地时的修正(2026-08-11)**:本节原先假定必须比对内部的 `objcIndexer.subclasses(of:)`, +因而断言「没有任何一次运行能同时拿到新旧两个序列」。核实后发现更干净的路子 —— +`RuntimeEngine.relationships(for:)` 这个**公开 API 在重构前后都存在且签名不变**, +现有的 `RelationshipsTests` 就是走它。基线因此可以直接采集用户可见的输出, +既不依赖任何将被删除的内部接口,也正好锁住真正要保住的东西。 + +采集仍须在升级 pin 之前完成 —— 升级后旧实现就没了,拿不到对照基线。 + +**同时修正一处对影响面的判断**:`RuntimeRelationshipsResolver` 在返回前把 +`subclasses` 与 `conformingTypes` **都按 `displayName` 排过序** +(`RuntimeRelationshipsResolver.swift:128-133`),所以库侧的走查顺序 / 事件发射顺序 +**到不了用户界面** —— Relationships 列表一直是字母序。 + +由此,「详细设计」等价性细节的三条中: +- 第 1 条(两个 conformance 事件写进同一张表)**仍然承重** —— 它关乎结果集的*内容*,分表会丢结果; +- 第 3 条(按三个字段去重)**仍然承重** —— 同样关乎内容,会影响某个类是否出现两次; +- 第 2 条(inline 整体在前)**只关乎内部表的保真度,不影响用户可见输出**。仍然照做(单队列回放 + 是最自然的实现,没有额外成本),但它不再是本提案的正确性支点。 + +0003 的契约五依旧成立,只是支撑它的是库侧自己的两条理由(顺序承诺的载体随方法一起被删、 +不给承诺会让 0003 否决「重新走查」的论证塌掉一半),而**不是**本提案原先声称的 +「并行化会静默改变 Relationships 顺序」—— 那条声称是错的,已向库侧更正。 + +### 快照覆盖 + +- `NSObject` 的子类集(跨 libobjc + Foundation,数量大) +- `NSCoding`、`NSCopying` 的遵守者集(inline 采纳与 category 采纳并存,验第 1 条等价性) + +### 三条测试 + +- **回归测试(必须先红后绿)**:构造一个**不传 `progressContinuation`** 的 + `RuntimeObjCSection`,断言其关系查询非空。这条直接钉住动机第二条那个静默失效。 + 它只有在依赖升级之后、handler 解耦之前才会红,因此落地步骤把它排在那个位置 —— **必须实际 + 观察到红色**,否则它不构成回归防线。 +- **等价性测试**:比对上述基线快照,序列完全一致(含顺序与重复项)。 +- **category 路径测试**:用测试 bundle 自带的 fixture,而不是去 Apple 的二进制里翻。 + 同模块内为 Swift 类写 `@objc extension` **通常不产出** `__objc_catlist` 条目(编译器掌控 + 自己模块里的类,会把成员直接并进 method list);可靠产出 category 的是**跨模块** extension: + + ```swift + // The explicit runtime name is REQUIRED, not stylistic: an `@objc protocol` + // without one is exposed to the ObjC runtime under its mangled spelling + // (`_TtP_`), which is what the indexer reads out of the binary + // — so the event payload would carry a name the test does not recognize. + @objc(ObjCIndexingFixtureProtocol) + protocol ObjCIndexingFixtureProtocol { func fixtureMethod() } + + @objc extension NSString: ObjCIndexingFixtureProtocol { + func fixtureMethod() {} + } + ``` + + 断言两条(形状取自库侧 0003 落地时的实测版本): + + ```swift + #expect(conformance.imagePath == fixturePath) // 测试 bundle,不是 Foundation + #expect(!indexer.classNames.contains("NSString")) // 目标类确实不在本 image + ``` + + 这就把「category 的 `imagePath` 是 category 所在 image、不是 target class 所在 image」 + 变成了可执行断言 —— 在系统 image 里碰运气拿不到这个。 + + 它钉不住 `isSwiftStable == true`(`NSString` 不是 Swift 类),也无法区分「解析成功且目标 + 非 Swift」与「解析失败兜底」—— 两者 payload 同形。要同时满足「必然产 category」和「target + 是 Swift 类」需要两个模块,成本另计,落地时视需要再定。 + +- 测试位置 `RuntimeViewerCore/Tests/RuntimeViewerCoreTests/`。落地时确认该 target 现有的 + image 加载设施可复用。 +- **判定只认 `swift test` 退出码**,不看 xcsift 摘要(见 AGENTS.md)。 + +## 风险与假设 + +- **假设**:库侧三个关系事件覆盖了原两张表的全部写入点。0003 的前期调研已确认「三者一一对应, + 没有第四条写入路径」。若落地时发现遗漏,等价性测试会立刻失败。 +- **风险**:库侧 `prepare()` 无幂等保护,重复调用会重放事件流,而本提案的累积队列不会自行去重。 + RuntimeViewer 每个 section 只在 `init` 里 prepare 一次且按 imagePath 缓存,当前不受影响; + 建表时的 `OrderedSet` 也会吸收完全相同的重复条目。仍需在类文档中写明这一前提。 +- **已关闭的风险 —— 并行化打破顺序等价**:本提案的逐条等价建立在「事件按走查顺序到达」之上, + 而 0003 初稿的契约四只说不承诺执行上下文,连带把顺序也放掉了。经反馈,0003 已增补**契约五**: + 同一 image、同一库版本,两次 `prepare()` 产生完全相同的事件序列;class 阶段全部事件先于 + category 阶段;**改变该顺序算破坏性变更,须走提案**。因此并行化不再会静默改变 Relationships + 的顺序,本提案的等价性目标与测试策略均无需调整。 +- **残留风险**:库侧若并行化走查(契约四允许),`record(_:)` 的锁会从"零竞争"变成真正的竞争点。 + 单队列 append 仍然正确、顺序仍由契约五保证,只是那时值得重新评估累积策略的性能。 + 不在本提案范围内。 + +## 替代方案考量 + +### 保留 handler 的条件安装,另外补一条无条件的关系 handler + +**为什么否**:两个 handler 意味着两条事件订阅路径,而库只接受一个 `eventHandler`。真要做只能在 +外层再包一层分发,比直接在单一 handler 内分流更绕,且把「关系必须无条件收集」这个契约藏得更深。 + +### 关系索引挂在工厂上,做成跨 image 的单一大表 + +**为什么否**:那等于把库刚删掉的聚合原样搬到应用侧。`RuntimeRelationshipsResolver` 现有的 +「遍历 image、逐个查」结构已经能工作,且天然随 section 的移除而释放;单一大表会重新引入 +`f41648a` 修过的那类生命周期问题。 + +### 等 0002(`MachOFile` 泛型化)一起做 + +**为什么否**:0002 在库侧尚是 Draft,且 0003 明确排在它之前落地。把下游适配压到 0002 之后, +意味着 main 长期停在旧 pin 上,与「库发版后同批次落地」的交付约定冲突。 + +## 落地步骤 + +每一步的验收都是单一确定的状态,按编号顺序执行即可。 + +0. **采集基线快照** —— 在动任何依赖之前,用现有实现 dump 目标序列落盘。见「测试策略」, + 这是一次性窗口。验收:快照文件存在且非空。 +1. **升级依赖**:MachOObjCSection pin `0.8.102` → `0.8.103`。 + 验收:编译**失败**(两个查询方法与 `ObjCClassReference` 已不存在),属预期。 +2. **新增 `RuntimeObjCClassReference` 与 `RuntimeObjCRelationshipIndex`**,在 + `RuntimeObjCSection` 的两个 `init` 中构造并作为 handler 传入;handler **暂时保持**现有的 + 条件安装(这是下一步要观察的失效条件)。同时切换 `RuntimeRelationshipsResolver` 的两处查询 + 与 `materializeRelationshipReference(_:)` 的参数类型。 + 验收:编译通过。 +3. **写回归测试并跑出红色** —— 不带 `progressContinuation` 创建的 section,断言其关系查询非空。 + 验收:该测试**失败**。红色本身就是这一步的验收标准;跑不出红说明测试没打中,先修测试。 +4. **handler 解耦**:`makeEventHandler` 改为总是返回 handler,`.progress` 的转发才看 + continuation。验收:第 3 步的测试转绿。 +5. **补齐等价性测试与 category fixture 测试**,比对第 0 步的快照。 + 验收:全部通过。 +6. **删除工厂聚合**:`objcInterfaceIndexer` 的声明、构造与两处 `addSubIndexer`。 + 验收:编译通过,`swift test` 退出码为 0。 +7. **写契约文档**:`RuntimeObjCRelationshipIndex` 的类文档写明「关系数据只经 `eventHandler` + 进入,未安装 handler 即放弃关系数据」以及事件重放的前提。**不允许留到最后补** —— 这是 + 本改动唯一一处靠文档而非类型系统兜住的地方。 +8. **收尾**:更新本篇状态;判断是否值得在 `Documentations/Internal/` 单独成篇 + (判据是它是否包含代码本身看不出来的决策,目前倾向值得 —— handler 必须无条件安装这一点 + 从签名上完全看不出来)。 + +## 落地记录(2026-08-11) + +代码已完成,**尚未提交,且暂时无法用远端 pin 构建**,见下方「阻塞」。 + +### 红绿两态都观察到了 + +- **红**:`RelationshipsWithoutProgressStreamTests` 在 handler 仍为条件安装时失败, + 失败形态正是预期的静默空集 —— `relationships.subclasses → []`,无任何报错。 + 同一时刻等价性快照也红了。 +- **绿**:handler 改为无条件安装后,两者同时转绿。 + +### 等价性:逐字一致 + +基线快照(升级依赖前用旧实现采集,`Snapshots/relationships-baseline.txt`)与迁移后输出 +**完全相同**:`NSObject` 307 个子类、`NSCoding` 9 个遵守者、`NSCopying` 68 个遵守者, +顺序与 `imagePath` 均一致。 + +测试:`RuntimeViewerCore` 全量 392 tests / 78 suites 通过,`swift test` 退出码 0 +(不看 xcsift 摘要);`RuntimeViewerPackages` 编译通过。 + +### 与提案的差异 + +1. **聚合删除提前到第 2 步。** 提案排在第 6 步,但 `addSubIndexer` 的调用点在库升级后 + 直接编译不过,不删就无法进入任何可运行状态。不影响 handler 解耦的红绿观察顺序。 +2. **category 测试改为对索引的单元测试。** 提案原计划用测试 bundle 的 + `@objc extension NSString` fixture 走公开 API 断言。实际跑下来 fixture 协议能被索引到, + 但 `NSString` **不会**作为遵守者出现 —— 原因见下条发现。断言层次因此下移到 + `RuntimeObjCRelationshipIndex` 本身(`RuntimeObjCRelationshipIndexTests`,8 个用例), + 用合成事件精确钉住三条等价性 + category 的 `imagePath` 语义;真实二进制的端到端保真 + 由等价性快照负责。 +3. **`freeze()` 的替代实现多了一条路径。** 惰性建表后,若事件在建表**之后**到达, + 不能作废重建(待处理队列已在建表时释放,重建会丢掉先前全部事件),改为直接折进已建好的表。 + 已加测试 `lateEventsStillRegister` 钉住。 + +### 落地中发现的既有缺陷(未修,不属本提案范围) + +**目标类不在本镜像的 category 遵守关系,在物化阶段被静默丢弃。** + +`RuntimeRelationshipsResolver.materializeRelationshipReference(_:)` 按 `reference.imagePath` +去定位类,而 category 记录的是**自己所在镜像**。于是「A 框架给 B 框架的类加 category 并声明 +协议遵守」这种关系,索引里有、界面上没有 —— 物化时在 A 镜像里找不到那个类,返回 `nil` 丢弃。 + +这是既有行为,与本次迁移无关(物化逻辑和 `imagePath` 语义都未改动),等价性快照也证明 +迁移前后完全一致。修它属于行为变更(会让 Relationships 出现此前从未出现过的条目), +应另开提案。 + +### 阻塞:远端 pin 暂时升不上去 + +`MachOSwiftSection` 0.15.0(含其当前 main)把 `MachOObjCSection` 钉在 `exact: "0.8.102"`, +而本改动需要 `0.8.103`。两者都用 `exact:`,因此 RuntimeViewer 无法同时满足: + +``` +'machoswiftsection' 0.15.0 depends on 'machoobjcsection' 0.8.102 +and root depends on 'machoobjcsection' 0.8.103 +``` + +**本分支因此暂时只能用 `USING_LOCAL_DEPENDENCIES=1` 走本地 checkout 构建与测试** +(上述全部验收均在该模式下完成)。合入 main 的前置条件是 MachOSwiftSection 把它的 +`MachOObjCSection` pin 提到 `0.8.103` 或更高并发版。 + +`Package.swift` 已写成目标状态 `exact: "0.8.103"`,等上游发版后即可直接远端构建验证。 + +### 未采纳:直接升到 `0.8.104` + +库侧建议 pin 直接指向 `0.8.104`(含一个 setter strip 的行为修复)。**未采纳** —— +那会让本次纯等价迁移夹带一个用户可见的输出变更,与提案「非目标」冲突。 +`0.8.104` 应作为独立改动落地,并单独评估它对 ObjC 接口输出基线的影响。 + +## 决策日志 + +| 日期 | 变更 | 说明 | +|------|------|------| +| 2026-08-10 | Created as Draft | 用户定方向「把 ObjC 搬进来,Swift 不动」,库侧对应 MachOObjCSection 0003。本篇只做下游适配,方向论证不重复 | +| 2026-08-10 | handler 无条件安装 | 核实调用点后确认:7 处 section 创建只有 1 处传 `progressContinuation`,后台索引那条也不传。沿用条件安装会让绝大多数 image 静默失去关系数据。这是本提案的主要风险点,也是它值得单独成篇的理由 | +| 2026-08-10 | 关系索引 per-image,不做工厂级大表 | 沿用 `RuntimeRelationshipsResolver` 现有的「遍历 image 逐个查」结构。做成跨 image 大表等于把库刚删的聚合搬过来,并重新引入 `f41648a` 修过的生命周期问题 | +| 2026-08-10 | 工厂聚合一并删除 | 该聚合只写不读(四处引用全是声明/构造/注册)。0003 删掉库侧 `addSubIndexer` 后它也无法构造。附带解掉 `feature/node-store-adoption` 与 main 之间关于 `removeSubIndexer` 的合并冲突 | +| 2026-08-10 | 回归测试必须先观察到红 | 该失效是静默的(无报错、结果为空),只有先跑出红色才能证明测试抓住了它。因此落地步骤把测试排在依赖升级之后、handler 解耦之前 | +| 2026-08-10 | 建表改为惰性,不引入 `freeze()` 时序契约 | 原设计要求 `prepare()` 之后调用 `freeze()`。库侧评审指出这会新造两条静默失效路径 —— 忘了调、以及 `prepare()` 抛错跳过调用点(它是 `async throws`)—— 而这正是本提案动机第二条要消灭的病症。改为查询时惰性建表,`prewarm()` 降级为可选预热。少一条契约优于多一条文档 | +| 2026-08-10 | 单一累积队列是行为等价的前提,不只是性能选择 | 库侧先走完 class 列表(`:272`)再走 category 列表(`:350`),故同一协议的 conformer 中 inline 采纳整体排在 category 贡献之前。按 case 分成两张表再合并,无论怎么拼都无法还原该顺序 | +| 2026-08-10 | 不"顺手"修同类双 `isSwiftStable` 重复条目 | inline 与 category 两条路径的 `isSwiftStable` 来源不同(后者跨 image 解析、失败兜底 `false`),罕见组合下会产生 `className` 相同而标志不同的两条引用。这是现有行为;按 `className` 去重看似修 bug,实为行为变更 | +| 2026-08-10 | 基线快照必须在升级依赖前采集 | 库侧评审指出「改动前后各跑一次」做不到 —— 改动后旧查询方法已不存在,没有一次运行能同时拿到新旧序列。这是一次性窗口,错过需回退依赖重跑 | +| 2026-08-10 | 保留 `record(_:)` 的锁 | 原文写「锁只为满足 `@Sendable` 检查而非防竞争」,把当前实现当成了 API 契约。0003 契约四明确库不承诺 `eventHandler` 的执行上下文,`@Sendable` 正是为 0002 之后并行化走查留的余地。锁必需,理由改为「当前实现下竞争接近零,成本可忽略」 | +| 2026-08-10 | category fixture 用跨模块 extension,不用同模块 | 原建议在测试 bundle 内为自有 Swift 类写 `@objc extension`,库侧评审指出同模块 extension 通常被直接并进 class 的 method list、不产出 `__objc_catlist` 条目。改为对 `NSString` 这类跨模块 ObjC 类写 extension。附带收益:其 `imagePath` 是测试 bundle,正好把 category 的 imagePath 语义变成可执行断言 | +| 2026-08-10 | 反向要求库承诺发射顺序,0003 增补契约五 | 契约四只说不承诺执行上下文,但连带把发射顺序也放掉了 —— 而本提案的逐条等价正建立在顺序之上,并行化会静默改变 Relationships 列表顺序。库侧接受拆分,并给出更强的理由:顺序承诺**今天已经存在**(`OrderedSet` 插入序 + doc comment + README 白纸黑字),表一移出只是失去载体,不转移到事件上等于静默撤销一个已对外给出的保证。因此本提案的等价性目标得以保住 | +| 2026-08-10 | `RuntimeObjCClassReference` 不带 `Codable`、不设 `public` | 核实全仓库对原库类型的引用只有两处(一处 doc comment、一个 `private` 方法签名),关系引用从不跨进程 —— 越过 XPC 的是已物化的 `RuntimeObject` | diff --git a/Documentations/Evolutions/README.md b/Documentations/Evolutions/README.md index acad6e06..89c69d1c 100644 --- a/Documentations/Evolutions/README.md +++ b/Documentations/Evolutions/README.md @@ -17,6 +17,7 @@ | [0003](0003-generic-type-specialization.md) | 泛型类型特化 | In Progress | 用户在 Inspector 的 Specialization tab 为泛型类型选定具体类型组合,特化结果作为 sidebar 子节点呈现,泛型参数被替换且 metadata 字段填上真实数值。 | | [0004](0004-differentiable-box-lazy-cellvm.md) | DifferentiableBox 与 Lazy Cell ViewModel 渲染范式 | Draft | 在 `RuntimeViewerArchitectures` 引入 `DifferentiableBox`,把任意 `Hashable` 领域模型适配为 DifferenceKit 的 `Differentiable`,使表格与大纲视图的 Rx 数据源走「轻量身份元素 + cell 级惰性 ViewModel」。 | | [0006](0006-mcp-transport-bind-failure-teardown.md) | MCP Transport 绑定失败的资源回收与状态如实化 | Implemented | 绑定失败改为显式 `start()` 判定:失败即回收 transport(线程 56→0)、`serverState` 如实 `.stopped`、端口文件带所有权守卫不误删他人文件。残余 5.57 MiB 为上游 SwiftMCP adapter↔engine 引用环,与线程数硬编码一并列为上游跟进项。 | +| [0007](0007-objc-relationship-index-returns-to-application.md) | ObjC 关系索引归还应用侧 | In Progress | MachOObjCSection 0003 的下游适配:库不再存继承 / 遵守反向表,改由应用在 `prepare()` 期间从 `ObjCIndexingEvent` 事件流重建。核心风险是事件 handler 目前条件安装(7 处 section 创建仅 1 处传进度流),不解耦会让后台索引的 image 静默失去关系数据。同批次删除只写不读的工厂聚合。 | > 0000 与 0001 采用早期格式,正文没有状态字段,此处如实标为「未标注」。按「旧文档原地不动」的约定不回填。 diff --git a/Documentations/README.md b/Documentations/README.md index ed3bc454..5c85d02a 100644 --- a/Documentations/README.md +++ b/Documentations/README.md @@ -18,7 +18,7 @@ ## 提案(Evolutions) -见 [`Evolutions/README.md`](Evolutions/README.md)。当前 5 篇:Bonjour 可靠性、IDA 兼容导出、后台索引、泛型类型特化、DifferentiableBox 渲染范式。 +见 [`Evolutions/README.md`](Evolutions/README.md)。当前 7 篇:Bonjour 可靠性、IDA 兼容导出、后台索引、泛型类型特化、DifferentiableBox 渲染范式、MCP Transport 绑定失败回收、ObjC 关系索引归还应用侧。 ## 设计与实现计划(Plans,归档) diff --git a/RuntimeViewerCore/Package.resolved b/RuntimeViewerCore/Package.resolved index 58080d97..def6b7ed 100644 --- a/RuntimeViewerCore/Package.resolved +++ b/RuntimeViewerCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5c3d5d7723e43596fdf07100a113291c3931f7539a940f5005b0ffba2fc2687d", + "originHash" : "ad07950f3a36cd48081d9113d0221bfe898ef384dfe68490dc4fb00453a8921e", "pins" : [ { "identity" : "associatedobject", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/FrameworkToolbox", "state" : { - "revision" : "6ee952112ea50f21194a56f805ae158e5de1bdb8", - "version" : "0.7.5" + "revision" : "3be067f4280e0764cd4b6cd3ed623698ac981114", + "version" : "0.9.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachInjector", "state" : { - "revision" : "e558c427e13f3b7abf97fa4b46e96d65413ff7c4", - "version" : "0.4.2" + "revision" : "c65d9c26af12c6d90e114eba6896eb1cf56dd64f", + "version" : "0.4.3" } }, { @@ -60,8 +60,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKit", "state" : { - "revision" : "4374bafbe4c628c05e068382a12467e7971b29ac", - "version" : "0.51.101" + "revision" : "5223d0958aa5aa810e8f77b6c6763f019254a322", + "version" : "0.52.100" + } + }, + { + "identity" : "machokitextensions", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Reverse-Engineering/MachOKitExtensions", + "state" : { + "revision" : "fde952e9b9af76e32e179f37098b6d259102769e", + "version" : "0.1.0" } }, { @@ -69,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", "state" : { - "revision" : "63e4b8a61d54fe90bb30e87c0c93f6a8b9c181f9", - "version" : "0.7.103" + "revision" : "415430845bcafd2309cba7c822e260a3cb811523", + "version" : "0.8.102" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", "state" : { - "revision" : "3396cfd7332d40edb6aa3b0600758916701a8be4", - "version" : "0.14.1" + "revision" : "aa38ff50bcf0ba89173da4ea0a8417c8bc478aa4", + "version" : "0.15.0" } }, { @@ -231,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Mx-Iris/swift-helper-service", "state" : { - "revision" : "f0b834f47f876f41e39a691fb177ff76d3910c49", - "version" : "0.1.4" + "revision" : "454ad61ada4a25c43ed74fc8551684fd92e8f8ae", + "version" : "0.2.0" } }, { @@ -244,15 +253,6 @@ "version" : "0.6.0" } }, - { - "identity" : "swift-macro-toolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stackotter/swift-macro-toolkit", - "state" : { - "revision" : "d6ed555bb83c9f21a292e9769952e8f52610a6e2", - "version" : "0.9.0" - } - }, { "identity" : "swift-memberwise-init-macro", "kind" : "remoteSourceControl", @@ -294,14 +294,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", "state" : { - "revision" : "daea78f0c94305d69b3c59af337ea69eda53603b", - "version" : "0.2.0" + "revision" : "1021589b85fc647435b383307aa6aea2af1079ce", + "version" : "0.3.0" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", "version" : "603.0.2" @@ -312,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/MxIris-macOS-Library-Forks/SwiftyXPC", "state" : { - "revision" : "8c0a1738ba68540e6e911eb409fca8a4603d3efd", - "version" : "0.5.103" + "revision" : "4f67b80d2d21b0968ec6c6c45aa01389f1f1ed05", + "version" : "0.5.104" } }, { diff --git a/RuntimeViewerCore/Package.swift b/RuntimeViewerCore/Package.swift index 05bf12d3..d4753d53 100644 --- a/RuntimeViewerCore/Package.swift +++ b/RuntimeViewerCore/Package.swift @@ -84,7 +84,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOKit", - exact: "0.51.101", + exact: "0.52.100", ), ), .package( @@ -94,7 +94,7 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOObjCSection", - exact: "0.7.103", + exact: "0.8.103", ), ), .package( @@ -104,7 +104,17 @@ let package = Package( ), remote: .package( url: "https://github.com/MxIris-Reverse-Engineering/MachOSwiftSection", - exact: "0.14.1", + exact: "0.15.0", + ), + ), + .package( + local: .package( + path: "../../swift-semantic-string", + isRelative: true, + ), + remote: .package( + url: "https://github.com/MxIris-Reverse-Engineering/swift-semantic-string", + from: "0.3.0", ), ), .package( @@ -173,8 +183,13 @@ let package = Package( "RuntimeViewerCommunication", .product(name: "MachOKit", package: "MachOKit"), .product(name: "MachOObjCSection", package: "MachOObjCSection"), + .product(name: "ObjCDeclarationRendering", package: "MachOObjCSection"), + .product(name: "ObjCIndexing", package: "MachOObjCSection"), + .product(name: "ObjCInterface", package: "MachOObjCSection"), .product(name: "MachOSwiftSection", package: "MachOSwiftSection"), - .product(name: "OutputTransformer", package: "MachOSwiftSection"), + .product(name: "OutputTransformer", package: "swift-semantic-string"), + .product(name: "ObjCOutputTransformer", package: "MachOObjCSection"), + .product(name: "SwiftOutputTransformer", package: "MachOSwiftSection"), .product(name: "SwiftDeclaration", package: "MachOSwiftSection"), .product(name: "SwiftDeclarationRendering", package: "MachOSwiftSection"), .product(name: "SwiftIndexing", package: "MachOSwiftSection"), @@ -225,6 +240,9 @@ let package = Package( dependencies: [ "RuntimeViewerCore", "RuntimeViewerCommunication", + .product(name: "OutputTransformer", package: "swift-semantic-string"), + .product(name: "ObjCOutputTransformer", package: "MachOObjCSection"), + .product(name: "SwiftOutputTransformer", package: "MachOSwiftSection"), ], ), .testTarget( diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/ObjCIndexingEvent+LoadingProgress.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/ObjCIndexingEvent+LoadingProgress.swift new file mode 100644 index 00000000..1f260a78 --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/ObjCIndexingEvent+LoadingProgress.swift @@ -0,0 +1,20 @@ +import Foundation +import ObjCIndexing + +extension ObjCIndexingEvent.Phase { + /// The RuntimeViewer loading phase this indexing phase maps onto. + /// + /// The library names its phases without the `ObjC` qualifier — it only + /// ever indexes Objective-C — while `RuntimeObjectsLoadingProgress.Phase` + /// spans both the ObjC and Swift halves of a load, so it keeps the + /// qualifier to stay unambiguous. + var loadingPhase: RuntimeObjectsLoadingProgress.Phase { + switch self { + case .indexingSubclasses: .indexingObjCSubclasses + case .loadingClasses: .loadingObjCClasses + case .loadingProtocols: .loadingObjCProtocols + case .indexingConformances: .indexingObjCConformances + case .loadingCategories: .loadingObjCCategories + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index a86f529e..8435d446 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -9,34 +9,15 @@ import Semantic import Utilities import MetaCodable -typealias LoadingEventContinuation = AsyncThrowingStream.Continuation +/// `ObjCGenerationOptions`, the ObjC interface indexer and the whole +/// `ObjCDumpInfo → SemanticString` renderer now live library-side in +/// MachOObjCSection. This re-export keeps every existing +/// `ObjCGenerationOptions` reference in RuntimeViewer compiling unchanged. +@_exported import ObjCDeclarationRendering +@_exported import ObjCIndexing +import ObjCInterface -@Codable -@MemberInit -public struct ObjCGenerationOptions: Sendable, Equatable { - @Default(false) - public var stripProtocolConformance: Bool - @Default(false) - public var stripOverrides: Bool - @Default(false) - public var stripSynthesizedIvars: Bool - @Default(false) - public var stripSynthesizedMethods: Bool - @Default(false) - public var stripCtorMethod: Bool - @Default(false) - public var stripDtorMethod: Bool - @Default(false) - public var addIvarOffsetComments: Bool - @Default(false) - public var addPropertyAttributesComments: Bool - @Default(false) - public var addMethodIMPAddressComments: Bool - @Default(false) - public var addPropertyAccessorAddressComments: Bool - - public static let `default` = Self() -} +typealias LoadingEventContinuation = AsyncThrowingStream.Continuation @Loggable(.private) actor RuntimeObjCSection { @@ -53,17 +34,26 @@ actor RuntimeObjCSection { /// Per-image Objective-C interface index: the parsed data store for /// this image (classes, protocols, categories, C struct/union - /// definitions) plus the inheritance / protocol-adoption reverse - /// tables. Constructed in `init` with this image's `MachOImage` and + /// definitions). Constructed in `init` with this image's `MachOImage` and /// populated by `objcIndexer.prepare()`; afterwards this section only /// *reads* it back to translate into `RuntimeViewerCore` domain types /// (`RuntimeObject`, `RuntimeObjectInterface`, `RuntimeMemberAddress`). /// - /// `nonisolated let` so `RuntimeRelationshipsResolver` and the factory's - /// aggregate can read its query methods without an actor hop — - /// `RuntimeObjCInterfaceIndexer` is `Sendable` and protects its own - /// state with `@Mutex`. - nonisolated let objcIndexer: RuntimeObjCInterfaceIndexer + /// Relationships are **not** in here — since MachOObjCSection 0003 the + /// library keeps no reverse tables; see `objcRelationshipIndex`. + /// + /// `nonisolated let` so `RuntimeRelationshipsResolver` can read its query + /// methods without an actor hop — `ObjCInterfaceIndexer` is `Sendable` and + /// protects its own state with its own lock. + nonisolated let objcIndexer: ObjCInterfaceIndexer + + /// Inheritance and protocol-adoption reverse tables for this image, + /// accumulated from the indexer's event stream during `prepare()`. + /// + /// `nonisolated let` for the same reason as `objcIndexer`: + /// `RuntimeRelationshipsResolver` reads it without an actor hop, and the + /// index guards its own state. + nonisolated let objcRelationshipIndex: RuntimeObjCRelationshipIndex init(imagePath: String, factory: RuntimeObjCSectionFactory, progressContinuation: LoadingEventContinuation? = nil) async throws { #log(.info, "Initializing ObjC section for image: \(imagePath, privacy: .public)") @@ -74,8 +64,17 @@ actor RuntimeObjCSection { self.machO = machO self.imagePath = imagePath self.factory = factory - self.objcIndexer = RuntimeObjCInterfaceIndexer(machO: machO, imagePath: imagePath) - try await objcIndexer.prepare(progressContinuation: progressContinuation) + let objcRelationshipIndex = RuntimeObjCRelationshipIndex() + self.objcRelationshipIndex = objcRelationshipIndex + self.objcIndexer = ObjCInterfaceIndexer( + machO: machO, + imagePath: imagePath, + eventHandler: Self.makeEventHandler( + recordingInto: objcRelationshipIndex, + forwardingTo: progressContinuation + ) + ) + try await objcIndexer.prepare() } init(machO: MachOImage, factory: RuntimeObjCSectionFactory, progressContinuation: LoadingEventContinuation? = nil) async throws { @@ -83,8 +82,54 @@ actor RuntimeObjCSection { self.machO = machO self.imagePath = machO.imagePath self.factory = factory - self.objcIndexer = RuntimeObjCInterfaceIndexer(machO: machO, imagePath: machO.imagePath) - try await objcIndexer.prepare(progressContinuation: progressContinuation) + let objcRelationshipIndex = RuntimeObjCRelationshipIndex() + self.objcRelationshipIndex = objcRelationshipIndex + self.objcIndexer = ObjCInterfaceIndexer( + machO: machO, + imagePath: machO.imagePath, + eventHandler: Self.makeEventHandler( + recordingInto: objcRelationshipIndex, + forwardingTo: progressContinuation + ) + ) + try await objcIndexer.prepare() + } + + /// Fans the library's single ``ObjCIndexingEvent`` channel out to its two + /// consumers: the relationship index, and RuntimeViewer's loading-progress + /// stream. + /// + /// **The handler is installed unconditionally**, and the returned closure is + /// non-optional on purpose. Since MachOObjCSection 0003 the relationship + /// events are the *only* channel carrying inheritance and protocol adoption: + /// an indexer built without a handler keeps none of it. Gating installation + /// on `progressContinuation` — as this did while the library still owned the + /// tables — would leave every image loaded through `_loadImage(at:)` or + /// background indexing with an empty Relationships pane and no error to show + /// for it. Only the *forwarding* of `.progress` events depends on a stream + /// being there to forward to. + private static func makeEventHandler( + recordingInto objcRelationshipIndex: RuntimeObjCRelationshipIndex, + forwardingTo progressContinuation: LoadingEventContinuation? + ) -> @Sendable (ObjCIndexingEvent) -> Void { + return { event in + objcRelationshipIndex.record(event) + guard let progressContinuation, + case .progress(let phase, let itemDescription, let currentCount, let totalCount) = event + else { + return + } + progressContinuation.yield( + RuntimeObjectsLoadingEvent.progress( + RuntimeObjectsLoadingProgress( + phase: phase.loadingPhase, + itemDescription: itemDescription, + currentCount: currentCount, + totalCount: totalCount + ) + ) + ) + } } func allObjects() async throws -> [RuntimeObject] { @@ -119,234 +164,71 @@ actor RuntimeObjCSection { func interface(for object: RuntimeObject, using options: ObjCGenerationOptions, transformer: Transformer.ObjCConfiguration) async throws -> RuntimeObjectInterface { #log(.debug, "Generating interface for: \(object.name, privacy: .public)") let name = object.withImagePath(imagePath) - let cTypeReplacements = transformer.cType.isEnabled ? transformer.cType.replacements : [:] - let ivarOffsetTransformer = transformer.ivarOffset.isEnabled ? transformer.ivarOffset : nil - let objcDumpContext = ObjCDumpContext(machO: machO, options: options, cTypeReplacements: cTypeReplacements) { name, isStruct in - guard let name else { return true } - if isStruct { - return !self.objcIndexer.containsStruct(named: name) - } else { - return !self.objcIndexer.containsUnion(named: name) + + // The strip switches, the C-type substitution and the rendering all + // live in MachOObjCSection's `ObjCInterface`; this section only maps + // RuntimeViewer's `Transformer` settings onto that API and wraps the + // result back into a `RuntimeObjectInterface`. + let builder = ObjCInterfaceBuilder(indexer: objcIndexer, machO: machO) + let cTypeReplacements = transformer.cType.isEnabled + ? transformer.cType.replacements.reduce(into: [ObjCPrimitiveTypePattern: String]()) { result, pair in + guard let pattern = ObjCPrimitiveTypePattern(rawValue: pair.key.rawValue) else { return } + result[pattern] = pair.value } + : [:] + let ivarOffsetCommentBuilder: (@Sendable (Int) -> String)? + if transformer.ivarOffset.isEnabled { + let module = transformer.ivarOffset + ivarOffsetCommentBuilder = { offset in module.transform(.init(offset: offset)) } + } else { + ivarOffsetCommentBuilder = nil } - objcDumpContext.ivarOffsetTransformer = ivarOffsetTransformer + let interfaceString: SemanticString? switch name.kind { case .objc(.type(.class)): - if let classGroup = objcIndexer.classGroup(forName: name.name), let currentClassInfo = classGroup.info.first { - let superclassInfos = classGroup.info.dropFirst() - var finalClassInfo = classGroup.info.first - var needsStripClassProperties: Set = [] - var needsStripProperties: Set = [] - var needsStripClassMethods: Set = [] - var needsStripMethods: Set = [] - var needsStripIvars: Set = [] - - if options.stripCtorMethod { - needsStripMethods.insert(".cxx_construct") - } - - if options.stripDtorMethod { - needsStripMethods.insert(".cxx_destruct") - } - - if options.stripOverrides { - for superclassInfo in superclassInfos { - needsStripClassProperties.insert(contentsOf: superclassInfo.classProperties.map(\.name)) - needsStripProperties.insert(contentsOf: superclassInfo.properties.map(\.name)) - needsStripClassMethods.insert(contentsOf: superclassInfo.classMethods.map(\.name)) - needsStripMethods.insert(contentsOf: superclassInfo.methods.map(\.name)) - } - } - if options.stripProtocolConformance { - for protocolInfo in currentClassInfo.protocols { - needsStripClassProperties.insert(contentsOf: protocolInfo.classProperties.map(\.name)) - needsStripProperties.insert(contentsOf: protocolInfo.properties.map(\.name)) - needsStripClassMethods.insert(contentsOf: protocolInfo.classMethods.map(\.name)) - needsStripMethods.insert(contentsOf: protocolInfo.methods.map(\.name)) - } - } - if options.stripSynthesizedIvars || options.stripSynthesizedMethods { - var needsStripIvarNames: Set = [] - - for property in currentClassInfo.properties + currentClassInfo.classProperties { - if options.stripSynthesizedMethods { - let propertyName = property.name - if let customGetter = property.customGetter { - if property.isClassProperty { - needsStripClassMethods.insert(customGetter) - } else { - needsStripMethods.insert(customGetter) - } - } else { - if property.isClassProperty { - needsStripClassMethods.insert(propertyName) - } else { - needsStripMethods.insert(propertyName) - } - } - - if let customSetter = property.customSetter { - if property.isClassProperty { - needsStripClassMethods.insert(customSetter) - } else { - needsStripMethods.insert(customSetter) - } - } else { - let setterMethodName = "set" + propertyName.uppercasedFirst - if property.isClassProperty { - needsStripClassMethods.insert(setterMethodName) - } else { - needsStripMethods.insert(setterMethodName) - } - } - } - - if options.stripSynthesizedIvars, !property.isClassProperty { - if let ivar = property.ivar { - needsStripIvarNames.insert(ivar) - } - } - } - - if options.stripSynthesizedIvars { - for ivar in currentClassInfo.ivars { - if needsStripIvarNames.contains(ivar.name) { - needsStripIvars.insert(ivar.name) - } - } - } - } - - finalClassInfo = ObjCClassInfo( - name: currentClassInfo.name, - version: currentClassInfo.version, - imageName: currentClassInfo.imageName, - instanceSize: currentClassInfo.instanceSize, - superClassName: currentClassInfo.superClassName, - protocols: currentClassInfo.protocols, - ivars: currentClassInfo.ivars.removingAll { needsStripIvars.contains($0.name) }, - classProperties: currentClassInfo.classProperties.removingAll { needsStripClassProperties.contains($0.name) }, - properties: currentClassInfo.properties.removingAll { needsStripProperties.contains($0.name) }, - classMethods: currentClassInfo.classMethods.removingAll { needsStripClassMethods.contains($0.name) }, - methods: currentClassInfo.methods.removingAll { needsStripMethods.contains($0.name) } - ) - - if let finalClassInfo { - if options.addPropertyAccessorAddressComments { - for method in currentClassInfo.methods where method.imp != 0 { - objcDumpContext.methodIMPs[method.name] = method.imp - } - for method in currentClassInfo.classMethods where method.imp != 0 { - objcDumpContext.classMethodIMPs[method.name] = method.imp - } - } - return .init(object: name, interfaceString: finalClassInfo.semanticString(using: objcDumpContext)) - } - } + interfaceString = builder.classInterface( + named: name.name, + options: options, + cTypeReplacements: cTypeReplacements, + ivarOffsetCommentBuilder: ivarOffsetCommentBuilder + ) case .objc(.type(.protocol)): - if let currentProtocolInfo = objcIndexer.protocolGroup(forName: name.name)?.info { - var finalProtocolInfo = currentProtocolInfo - - var needsStripClassProperties: Set = [] - var needsStripClassMethods: Set = [] - var needsStripProperties: Set = [] - var needsStripMethods: Set = [] - - if options.stripCtorMethod { - needsStripMethods.insert(".cxx_construct") - } - - if options.stripDtorMethod { - needsStripMethods.insert(".cxx_destruct") - } - - if options.stripProtocolConformance { - for protocolInfo in currentProtocolInfo.protocols { - needsStripClassProperties.insert(contentsOf: protocolInfo.classProperties.map(\.name)) - needsStripProperties.insert(contentsOf: protocolInfo.properties.map(\.name)) - needsStripClassMethods.insert(contentsOf: protocolInfo.classMethods.map(\.name)) - needsStripMethods.insert(contentsOf: protocolInfo.methods.map(\.name)) - - needsStripClassProperties.insert(contentsOf: protocolInfo.optionalClassProperties.map(\.name)) - needsStripProperties.insert(contentsOf: protocolInfo.optionalProperties.map(\.name)) - needsStripClassMethods.insert(contentsOf: protocolInfo.optionalClassMethods.map(\.name)) - needsStripMethods.insert(contentsOf: protocolInfo.optionalMethods.map(\.name)) - } - } - - if options.stripSynthesizedMethods { - for property in currentProtocolInfo.properties + currentProtocolInfo.classProperties + currentProtocolInfo.optionalProperties + currentProtocolInfo.optionalClassProperties { - let propertyName = property.name - if let customGetter = property.customGetter { - if property.isClassProperty { - needsStripClassMethods.insert(customGetter) - } else { - needsStripMethods.insert(customGetter) - } - } else { - if property.isClassProperty { - needsStripClassMethods.insert(propertyName) - } else { - needsStripMethods.insert(propertyName) - } - } - - if let customSetter = property.customSetter { - if property.isClassProperty { - needsStripClassMethods.insert(customSetter) - } else { - needsStripMethods.insert(customSetter) - } - } else { - let setterMethodName = "set" + propertyName.uppercasedFirst - if property.isClassProperty { - needsStripClassMethods.insert(setterMethodName) - } else { - needsStripMethods.insert(setterMethodName) - } - } - } - } - - finalProtocolInfo = ObjCProtocolInfo( - name: currentProtocolInfo.name, - protocols: currentProtocolInfo.protocols, - classProperties: currentProtocolInfo.classProperties.removingAll { needsStripClassProperties.contains($0.name) }, - properties: currentProtocolInfo.properties.removingAll { needsStripProperties.contains($0.name) }, - classMethods: currentProtocolInfo.classMethods.removingAll { needsStripClassMethods.contains($0.name) }, - methods: currentProtocolInfo.methods.removingAll { needsStripMethods.contains($0.name) }, - optionalClassProperties: currentProtocolInfo.optionalClassProperties.removingAll { needsStripClassProperties.contains($0.name) }, - optionalProperties: currentProtocolInfo.optionalProperties.removingAll { needsStripProperties.contains($0.name) }, - optionalClassMethods: currentProtocolInfo.optionalClassMethods.removingAll { needsStripClassMethods.contains($0.name) }, - optionalMethods: currentProtocolInfo.optionalMethods.removingAll { needsStripMethods.contains($0.name) } - ) - - return .init(object: name, interfaceString: finalProtocolInfo.semanticString(using: objcDumpContext)) - } + interfaceString = builder.protocolInterface( + named: name.name, + options: options, + cTypeReplacements: cTypeReplacements, + ivarOffsetCommentBuilder: ivarOffsetCommentBuilder + ) case .objc(.category(.class)): - if let categoryInfo = objcIndexer.categoryGroup(forName: name.name)?.info { - if options.addPropertyAccessorAddressComments { - for method in categoryInfo.methods where method.imp != 0 { - objcDumpContext.methodIMPs[method.name] = method.imp - } - for method in categoryInfo.classMethods where method.imp != 0 { - objcDumpContext.classMethodIMPs[method.name] = method.imp - } - } - return .init(object: name, interfaceString: categoryInfo.semanticString(using: objcDumpContext)) - } + interfaceString = builder.categoryInterface( + uniqueName: name.name, + options: options, + cTypeReplacements: cTypeReplacements, + ivarOffsetCommentBuilder: ivarOffsetCommentBuilder + ) case .c(.struct): - if let interfaceString = objcIndexer.structSemanticString(forName: name.name, context: objcDumpContext) { - return .init(object: name, interfaceString: interfaceString) - } + interfaceString = builder.structInterface( + named: name.name, + options: options, + cTypeReplacements: cTypeReplacements, + ivarOffsetCommentBuilder: ivarOffsetCommentBuilder + ) case .c(.union): - if let interfaceString = objcIndexer.unionSemanticString(forName: name.name, context: objcDumpContext) { - return .init(object: name, interfaceString: interfaceString) - } + interfaceString = builder.unionInterface( + named: name.name, + options: options, + cTypeReplacements: cTypeReplacements, + ivarOffsetCommentBuilder: ivarOffsetCommentBuilder + ) default: - break + interfaceString = nil } + + if let interfaceString { + return .init(object: name, interfaceString: interfaceString) + } + #log(.default, "Invalid runtime object: \(object.name, privacy: .public) kind: \(String(describing: object.kind), privacy: .public)") throw Error.invalidRuntimeObject } @@ -510,24 +392,15 @@ actor RuntimeObjCSection { @Loggable(.private) actor RuntimeObjCSectionFactory { - private var sections: [String: RuntimeObjCSection] = [:] - - /// Aggregate Objective-C interface indexer. Each per-image - /// `RuntimeObjCSection.objcIndexer` is registered as a sub-indexer when - /// the section is created, so queries against this aggregate fan out - /// across all loaded ObjC sections. Mirrors `RuntimeSwiftSectionFactory.indexer`. + /// Per-image sections, keyed by the dyld-canonical image path. /// - /// `RuntimeObjCInterfaceIndexer` binds a `MachOImage` at `init`; this - /// aggregate never parses one of its own (`prepare()` is never called on - /// it), so it is constructed against the current process image as a - /// placeholder — mirroring `RuntimeSwiftSectionFactory`'s aggregate, - /// which is likewise built `in: .current()`. - let objcInterfaceIndexer: RuntimeObjCInterfaceIndexer - - init() { - let currentMachO = MachOImage.current() - objcInterfaceIndexer = RuntimeObjCInterfaceIndexer(machO: currentMachO, imagePath: currentMachO.imagePath) - } + /// There is deliberately no aggregate indexer here, unlike + /// `RuntimeSwiftSectionFactory`. One existed until Evolution 0007 and was + /// only ever written to — every per-image indexer was registered with it and + /// nothing ever queried it, because `RuntimeRelationshipsResolver` fans out + /// across images itself. Keeping it alive would only pin each image's index + /// state for the engine's lifetime. + private var sections: [String: RuntimeObjCSection] = [:] func existingSection(for imagePath: String) -> RuntimeObjCSection? { sections[imagePath] @@ -554,7 +427,6 @@ actor RuntimeObjCSectionFactory { #log(.debug, "Creating ObjC section for: \(imagePath, privacy: .public)") let section = try await RuntimeObjCSection(imagePath: imagePath, factory: self, progressContinuation: progressContinuation) sections[imagePath] = section - objcInterfaceIndexer.addSubIndexer(section.objcIndexer) #log(.debug, "ObjC section created and cached") return (false, section) } @@ -575,7 +447,6 @@ actor RuntimeObjCSectionFactory { #log(.debug, "Creating ObjC section from MachO: \(machO.imagePath, privacy: .public)") let objcSection = try await RuntimeObjCSection(machO: machO, factory: self) sections[machO.imagePath] = objcSection - objcInterfaceIndexer.addSubIndexer(objcSection.objcIndexer) return objcSection } catch { #log(.error, "Failed to create ObjC section: \(error, privacy: .public)") diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift deleted file mode 100644 index 86c30e4f..00000000 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeObjCInterfaceIndexer.swift +++ /dev/null @@ -1,628 +0,0 @@ -import Foundation -import MachOKit -import MachOObjCSection -import ObjCDump -import ObjCTypeDecodeKit -import OrderedCollections -import Semantic -import SwiftStdlibToolbox - -/// A reference to an Objective-C class (or a Swift class that surfaces a -/// `class_t` record through `__objc_classlist`) that was found to subclass -/// another class or to adopt a protocol. -/// -/// `isSwiftStable` carries the structural signal that lets -/// `RuntimeRelationshipsResolver` decide whether to materialize the -/// reference as a Swift `RuntimeObject` (kind `.swift(.type(.class))`) or as -/// an Objective-C one (kind `.objc(.type(.class))`). Mirrors the same field -/// that `RuntimeObjCSection.allObjects()` already uses to mark bridged classes. -public struct ObjCClassReference: Hashable, Sendable, Codable { - public let className: String - public let imagePath: String - public let isSwiftStable: Bool - - public init(className: String, imagePath: String, isSwiftStable: Bool) { - self.className = className - self.imagePath = imagePath - self.isSwiftStable = isSwiftStable - } -} - -/// Per-image Objective-C interface index: the parsed data store for one -/// Mach-O image's classes, protocols, categories and C struct/union -/// definitions, plus the class-inheritance and protocol-adoption reverse -/// tables that back `RuntimeRelationshipsResolver`. -/// -/// This is the Objective-C counterpart of `SwiftDeclarationIndexer` on the -/// Swift side: it takes the image's `MachOImage` at `init` and owns *all* -/// of the raw `MachOObjCSection` / `ObjCDump` extraction (`prepare()`), so -/// `RuntimeObjCSection` is left as a thin translation layer that turns this -/// index into `RuntimeViewerCore` domain types (`RuntimeObject`, -/// `RuntimeObjectInterface`, `RuntimeMemberAddress`). -/// -/// Aggregation: a `RuntimeObjCSectionFactory` keeps one empty aggregate -/// instance and registers every per-image indexer as a sub-indexer via -/// `addSubIndexer(_:)`, so relationship queries can fan out across all -/// loaded ObjC images. Mirrors `SwiftDeclarationIndexer.addSubIndexer(_:)`. -/// -/// `@unchecked Sendable`: the `MachOImage` supplied at `init` and the -/// stored `ObjCDump` / `MachOObjCSection` values are not themselves -/// `Sendable`, but `machO` is an immutable `let` and every dictionary is -/// `@Mutex`-guarded and immutable once `prepare()` returns — mirroring the -/// `SwiftDeclarationIndexer` reference-type indexer pattern (a shared -/// `Sendable` data store), though the exact isolation annotations on each -/// side differ. -public final class RuntimeObjCInterfaceIndexer: @unchecked Sendable { - - // MARK: - Group Types - - /// A class paired with its own `ObjCClassInfo` plus the `ObjCClassInfo` - /// of every superclass (resolved across images), `info.first` being the - /// class itself. `internal` so `RuntimeObjCSection` can read the tuple. - typealias ObjCClassGroup = (objcClass: any ObjCClassProtocol, info: [ObjCClassInfo]) - - typealias ObjCProtocolGroup = (objcProtocol: any ObjCProtocolProtocol, info: ObjCProtocolInfo) - - typealias ObjCCategoryGroup = (objcCategory: any ObjCCategoryProtocol, info: ObjCCategoryInfo) - - // MARK: - C Struct / Union - - /// A C `struct` / `union` definition harvested from the ivar / method / - /// property type encodings of the image's ObjC metadata. - private struct CStructOrUnion: Hashable { - let name: String - - let fields: [ObjCField] - - var hasBitFieldOnly: Bool { - fields.allSatisfy { $0.bitWidth != nil } - } - - var numberOfHasNameFields: Int { - fields.count { $0.name != nil } - } - - @SemanticStringBuilder - func semanticString(isStruct: Bool, context: ObjCDumpContext) -> SemanticString { - Keyword(isStruct ? "struct" : "union") - Space() - TypeName(kind: .other, name) - Joined { - MemberList(level: 1) { - for (index, field) in fields.enumerated() { - field.semanticString(fallbackName: "x\(index)", level: 1, context: context) - } - } - } prefix: { - " {" - } suffix: { - Indent(level: 0) - "}" - } - } - } - - // MARK: - Indexed Image - - /// The Mach-O image this indexer parses. Bound at `init` and never - /// reassigned — mirrors `SwiftDeclarationIndexer`, which likewise binds - /// its `MachOImage` at construction. `prepare()` reads it to populate - /// the data store below. - private let machO: MachOImage - - /// The image path recorded into every `ObjCClassReference` and - /// translated `RuntimeObject`. Passed explicitly rather than derived - /// from `machO.imagePath`: `RuntimeObjCSection` may be constructed from - /// a path that differs from the resolved image's own path — e.g. in - /// Debug builds the main-executable stub path resolves to a sibling - /// `.debug.dylib` image whose `imagePath` is not the stub's. - private let imagePath: String - - // MARK: - Interface Data Store - - @Mutex - private var classes: [String: ObjCClassGroup] = [:] - - @Mutex - private var protocols: [String: ObjCProtocolGroup] = [:] - - @Mutex - private var categories: [String: ObjCCategoryGroup] = [:] - - @Mutex - private var structs: [String: CStructOrUnion] = [:] - - @Mutex - private var unions: [String: CStructOrUnion] = [:] - - // MARK: - Relationship Reverse Tables - - @Mutex - private var subclassesByClassName: [String: OrderedSet] = [:] - - @Mutex - private var conformingClassesByProtocolName: [String: OrderedSet] = [:] - - @Mutex - private var subIndexers: [RuntimeObjCInterfaceIndexer] = [] - - private let eventHandler: RuntimeObjCInterfaceEvents.Handler? - - /// `internal`, not `public`: the parameter type `MachOImage` comes from - /// a non-`public` import, and every construction site - /// (`RuntimeObjCSection`, `RuntimeObjCSectionFactory`) lives in this - /// module anyway. `prepare()` is likewise `internal`. - init(machO: MachOImage, imagePath: String, eventHandler: RuntimeObjCInterfaceEvents.Handler? = nil) { - self.machO = machO - self.imagePath = imagePath - self.eventHandler = eventHandler - } - - // MARK: - Preparation - - /// Parse this indexer's Mach-O image (`machO`, bound at `init`) into the - /// data store: every class / protocol / category, the C struct / union - /// definitions harvested from their type encodings, and — inline as the - /// `__objc_classlist` walk proceeds — the class-inheritance and - /// protocol-adoption reverse tables. - /// - /// Called once by `RuntimeObjCSection.init`, after which the store is - /// immutable. The aggregate indexer held by `RuntimeObjCSectionFactory` - /// never calls this — it only aggregates per-image sub-indexers. - func prepare(progressContinuation: LoadingEventContinuation? = nil) async throws { - var classByName: [String: ObjCClassGroup] = [:] - var protocolByName: [String: ObjCProtocolGroup] = [:] - var categoryByName: [String: ObjCCategoryGroup] = [:] - var structsByName: [String: CStructOrUnion] = [:] - var unionsByName: [String: CStructOrUnion] = [:] - var classInfoCache: [String: ObjCClassInfo] = [:] - - func setObjCType(_ type: ObjCType) { - switch type { - case .struct(let name, let fields): - if let name { - let newStruct = CStructOrUnion(name: name, fields: fields ?? []) - guard !newStruct.hasBitFieldOnly else { return } - if let existStruct = structsByName[name] { - if existStruct.numberOfHasNameFields < newStruct.numberOfHasNameFields { - structsByName[name] = newStruct - } - } else { - structsByName[name] = newStruct - } - } - case .union(let name, let fields): - if let name { - let newUnion = CStructOrUnion(name: name, fields: fields ?? []) - guard !newUnion.hasBitFieldOnly else { return } - if let existUnion = unionsByName[name] { - if existUnion.numberOfHasNameFields < newUnion.numberOfHasNameFields { - unionsByName[name] = newUnion - } - } else { - unionsByName[name] = newUnion - } - } - default: - break - } - } - - func setObjCTypeFromMethods(_ methods: [ObjCMethodInfo]) { - for method in methods { - if let returnType = method.returnType { - setObjCType(returnType) - } - - if let argumentInfos = method.argumentInfos { - for argumentInfo in argumentInfos { - setObjCType(argumentInfo.type) - } - } - } - } - - func setObjCTypeFromProperties(_ properties: [ObjCPropertyInfo]) { - for property in properties { - for attribute in property.attributes { - if let type = attribute.type { - setObjCType(type) - } - } - } - } - - let objcClasses: [any ObjCClassProtocol] = machO.objc.classes64.orEmpty + machO.objc.classes32.orEmpty + machO.objc.nonLazyClasses64.orEmpty + machO.objc.nonLazyClasses32.orEmpty - - // One-shot progress marker so the loading indicator can surface - // "Indexing Objective-C subclasses…" before the per-class loop - // starts pushing `.loadingObjCClasses` updates. Inheritance and - // protocol-adoption indexing happens inline below — every class in - // `__objc_classlist` (including Swift-derived ones via the same - // record format) is fed to the reverse tables as we walk the list. - progressContinuation?.yield(RuntimeObjectsLoadingEvent.progress(RuntimeObjectsLoadingProgress( - phase: .indexingObjCSubclasses, - itemDescription: "", - currentCount: 0, - totalCount: objcClasses.count - ))) - - for objcClass in objcClasses { - let objcClassGroup: ObjCClassGroup = (objcClass, infoWithSuperclasses(class: objcClass, in: machO, cache: &classInfoCache)) - guard let objcClassInfo = objcClassGroup.info.first else { continue } - classByName[objcClassInfo.name] = objcClassGroup - progressContinuation?.yield(RuntimeObjectsLoadingEvent.progress(RuntimeObjectsLoadingProgress( - phase: .loadingObjCClasses, - itemDescription: objcClassInfo.name, - currentCount: classByName.count, - totalCount: objcClasses.count - ))) - - // Feed the reverse tables. We pass the already-extracted class - // info — `superClassName` is resolved through MachO's bind/rebase - // walking by `infoWithSuperclasses`, so we don't redo that work - // here. `isSwiftStable` comes off the raw class_t record itself, - // exactly matching the field used by `RuntimeObjCSection` to mark - // bridged classes' `secondaryKind`. - indexClass( - className: objcClassInfo.name, - superClassName: objcClassInfo.superClassName, - adoptedProtocolNames: objcClassInfo.protocols.map(\.name), - imagePath: imagePath, - isSwiftStable: objcClass.isSwiftStable - ) - - for ivar in objcClassInfo.ivars { - if let type = ivar.type { - setObjCType(type) - } - } - - setObjCTypeFromProperties(objcClassInfo.properties + objcClassInfo.classProperties) - setObjCTypeFromMethods(objcClassInfo.methods + objcClassInfo.classMethods) - } - - // `__objc_protolist` carries a full `protocol_t` record for *every* - // protocol whose `@protocol` declaration was in scope at compile - // time — including ones merely imported from dependencies (`NSObject`, - // `NSCopying`, …), not just this image's own. We list all of them: - // there is no authoritative "defining image" recorded for a protocol - // (unlike classes, which are emitted exactly once in their own image's - // `__objc_classlist`), so every attempt at attributing ownership is a - // heuristic that can silently *drop* an image's real protocols. A - // previous dependency-closure heuristic did exactly that — see - // `Documentations/ResolvedIssues/2026-08-05-objc-protocol-ownership-filter.md`. - let objcProtocols: [any ObjCProtocolProtocol] = machO.objc.protocols64.orEmpty + machO.objc.protocols32.orEmpty - - for objcProtocol in objcProtocols { - guard let objcProtocolInfo = objcProtocol.info(in: machO) else { continue } - protocolByName[objcProtocolInfo.name] = (objcProtocol, objcProtocolInfo) - progressContinuation?.yield(RuntimeObjectsLoadingEvent.progress(RuntimeObjectsLoadingProgress( - phase: .loadingObjCProtocols, - itemDescription: objcProtocolInfo.name, - currentCount: protocolByName.count, - totalCount: objcProtocols.count - ))) - setObjCTypeFromProperties(objcProtocolInfo.properties + objcProtocolInfo.classProperties) - setObjCTypeFromMethods(objcProtocolInfo.methods + objcProtocolInfo.classMethods) - } - - var objcCategories: [any ObjCCategoryProtocol] = [] - - objcCategories.append(contentsOf: machO.objc.categories64.orEmpty) - objcCategories.append(contentsOf: machO.objc.categories32.orEmpty) - objcCategories.append(contentsOf: machO.objc.nonLazyCategories64.orEmpty) - objcCategories.append(contentsOf: machO.objc.nonLazyCategories32.orEmpty) - objcCategories.append(contentsOf: machO.objc.categories2_64.orEmpty) - objcCategories.append(contentsOf: machO.objc.categories2_32.orEmpty) - - // One-shot marker that conformance indexing starts; each category - // extends the conformer set of its target class for every protocol - // the category adopts. - progressContinuation?.yield(RuntimeObjectsLoadingEvent.progress(RuntimeObjectsLoadingProgress( - phase: .indexingObjCConformances, - itemDescription: "", - currentCount: 0, - totalCount: objcCategories.count - ))) - - for objcCategory in objcCategories { - guard let objcCategoryInfo = objcCategory.info(in: machO) else { continue } - categoryByName[objcCategoryInfo.uniqueName] = (objcCategory, objcCategoryInfo) - progressContinuation?.yield(RuntimeObjectsLoadingEvent.progress(RuntimeObjectsLoadingProgress( - phase: .loadingObjCCategories, - itemDescription: objcCategoryInfo.uniqueName, - currentCount: categoryByName.count, - totalCount: objcCategories.count - ))) - setObjCTypeFromProperties(objcCategoryInfo.properties + objcCategoryInfo.classProperties) - setObjCTypeFromMethods(objcCategoryInfo.methods + objcCategoryInfo.classMethods) - - // Feed category data to the reverse tables. The target class' - // Swift stable flag is read from the already-resolved class - // record so category adoptions on bridged classes (e.g. NSError - // extending Swift error protocols) carry `isSwiftStable == true` - // and surface as Swift `RuntimeObject` at query time. - let targetClassName = objcCategoryInfo.className - let targetIsSwiftStable: Bool - if let (_, targetClass) = objcCategory.class(in: machO) { - targetIsSwiftStable = targetClass.isSwiftStable - } else { - targetIsSwiftStable = false - } - indexCategory( - targetClassName: targetClassName, - targetIsSwiftStable: targetIsSwiftStable, - adoptedProtocolNames: objcCategoryInfo.protocols.map(\.name), - imagePath: imagePath - ) - } - - classes = classByName - protocols = protocolByName - categories = categoryByName - structs = structsByName - unions = unionsByName - } - - /// Resolve `cls` to its own `ObjCClassInfo` followed by the - /// `ObjCClassInfo` of every superclass, walking `superClass(in:)` across - /// image boundaries. `cache` memoizes `info(in:)` extraction so a deep - /// inheritance chain shared by many classes is decoded only once. - private func infoWithSuperclasses(class cls: Class, in machO: MachOImage, cache: inout [String: ObjCClassInfo]) -> [ObjCClassInfo] { - guard let className = cls.name(in: machO) else { return [] } - - var currentInfo: ObjCClassInfo? - - if let cacheInfo = cache[className] { - currentInfo = cacheInfo - } else { - let info = cls.info(in: machO) - currentInfo = info - cache[className] = info - } - - guard let currentInfo else { return [] } - - var resultInfos: [ObjCClassInfo] = [currentInfo] - - var machOAndSuperclass = cls.superClass(in: machO) - - while let currentMachOAndSuperclass = machOAndSuperclass { - let currentMachO = currentMachOAndSuperclass.0 - let currentSuperclass = currentMachOAndSuperclass.1 - - machOAndSuperclass = currentSuperclass.superClass(in: currentMachO) - - guard let superClassName = currentSuperclass.name(in: currentMachO) else { continue } - - var superclassInfo: ObjCClassInfo? - if let cacheInfo = cache[superClassName] { - superclassInfo = cacheInfo - } else { - let info = currentSuperclass.info(in: currentMachO) - superclassInfo = info - cache[superClassName] = info - } - if let superclassInfo { - resultInfos.append(superclassInfo) - } - } - - return resultInfos - } - - // MARK: - Reverse-table Feed - - /// Records one Objective-C class record from `__objc_classlist`: - /// - its superclass name -> add this class as a subclass entry - /// - each protocol it adopts inline -> add this class as a conformer - /// - /// `__objc_classlist` automatically contains a `class_t` record for every - /// Swift class with an Objective-C ancestor (`class Foo: NSObject`, - /// whether or not annotated `@objc`). Pass `isSwiftStable: true` for those - /// so the resolver can materialize the reference as a Swift `RuntimeObject` - /// at query time without doing any string-name bridging. - private func indexClass( - className: String, - superClassName: String?, - adoptedProtocolNames: [String], - imagePath: String, - isSwiftStable: Bool - ) { - let reference = ObjCClassReference( - className: className, - imagePath: imagePath, - isSwiftStable: isSwiftStable - ) - - if let superClassName, !superClassName.isEmpty { - // `_ =` drops `OrderedSet.append`'s `(inserted:index:)` tuple so - // the `withLock` closure stays `Void`-returning; a repeated - // reference being deduped by `OrderedSet` is the intended behavior. - _subclassesByClassName.withLock { dictionary in - _ = dictionary[superClassName, default: []].append(reference) - } - eventHandler?( - RuntimeObjCInterfaceEvents.Event( - kind: .subclassIndexed( - className: className, - superclass: superClassName, - imagePath: imagePath - ) - ) - ) - } - - for protocolName in adoptedProtocolNames { - _conformingClassesByProtocolName.withLock { dictionary in - _ = dictionary[protocolName, default: []].append(reference) - } - eventHandler?( - RuntimeObjCInterfaceEvents.Event( - kind: .conformanceIndexed( - className: className, - protocolName: protocolName, - imagePath: imagePath - ) - ) - ) - } - } - - /// Records one Objective-C category. Categories extend the conformance - /// set of the target class: every protocol the category adopts gets the - /// target class added as a conformer (with the target's `isSwiftStable` - /// flag carried through, so a category on a bridged class still surfaces - /// the class as Swift). - private func indexCategory( - targetClassName: String, - targetIsSwiftStable: Bool, - adoptedProtocolNames: [String], - imagePath: String - ) { - let reference = ObjCClassReference( - className: targetClassName, - imagePath: imagePath, - isSwiftStable: targetIsSwiftStable - ) - - for protocolName in adoptedProtocolNames { - _conformingClassesByProtocolName.withLock { dictionary in - _ = dictionary[protocolName, default: []].append(reference) - } - eventHandler?( - RuntimeObjCInterfaceEvents.Event( - kind: .categoryConformanceIndexed( - targetClassName: targetClassName, - protocolName: protocolName, - imagePath: imagePath - ) - ) - ) - } - } - - // MARK: - Interface Query - - /// The class plus its superclass chain for `name`, or `nil` if `name` is - /// not a class in this image. `info.first` is the class itself. - func classGroup(forName name: String) -> ObjCClassGroup? { - classes[name] - } - - /// The protocol record for `name`, or `nil` if `name` is not a protocol - /// in this image. - func protocolGroup(forName name: String) -> ObjCProtocolGroup? { - protocols[name] - } - - /// The category record for `uniqueName`, or `nil` if absent. - func categoryGroup(forName uniqueName: String) -> ObjCCategoryGroup? { - categories[uniqueName] - } - - /// Names of every class in this image (`__objc_classlist` order is not - /// preserved — dictionary iteration order). - var classNames: [String] { - Array(classes.keys) - } - - var protocolNames: [String] { - Array(protocols.keys) - } - - var categoryNames: [String] { - Array(categories.keys) - } - - var structNames: [String] { - Array(structs.keys) - } - - var unionNames: [String] { - Array(unions.keys) - } - - /// Whether a C `struct` named `name` was harvested from this image — - /// used by `ObjCDumpContext` to decide whether a referenced struct - /// should be emitted inline or left as a forward declaration. - func containsStruct(named name: String) -> Bool { - structs[name] != nil - } - - func containsUnion(named name: String) -> Bool { - unions[name] != nil - } - - /// The rendered interface of the C `struct` named `name`, or `nil` if - /// absent. The `context` is supplied by `RuntimeObjCSection` because it - /// depends on per-request generation options. - func structSemanticString(forName name: String, context: ObjCDumpContext) -> SemanticString? { - structs[name]?.semanticString(isStruct: true, context: context) - } - - func unionSemanticString(forName name: String, context: ObjCDumpContext) -> SemanticString? { - unions[name]?.semanticString(isStruct: false, context: context) - } - - // MARK: - Relationship Query - - /// All directly subclassing references for the given Objective-C class - /// name, gathered from this indexer's own per-image data plus every - /// sub-indexer registered via `addSubIndexer`. Insertion order is - /// preserved across a single image; cross-image order follows - /// `subIndexers` registration order. - public func subclasses(of className: String) -> [ObjCClassReference] { - var result: OrderedSet = subclassesByClassName[className] ?? [] - for subIndexer in subIndexers { - for reference in subIndexer.subclasses(of: className) { - result.append(reference) - } - } - return Array(result) - } - - /// All classes (across all sub-indexers) that adopt the given protocol - /// either inline (`@interface …

`) or via a category that adopts the - /// protocol on the class. - public func conformingClasses(toProtocol protocolName: String) -> [ObjCClassReference] { - var result: OrderedSet = conformingClassesByProtocolName[protocolName] ?? [] - for subIndexer in subIndexers { - for reference in subIndexer.conformingClasses(toProtocol: protocolName) { - result.append(reference) - } - } - return Array(result) - } - - // MARK: - Aggregation - - /// Registers a per-image indexer with this aggregate. Mirrors - /// `SwiftDeclarationIndexer.addSubIndexer(_:)` and is called by - /// `RuntimeObjCSectionFactory` immediately after a new per-image - /// `RuntimeObjCSection` has been constructed. - public func addSubIndexer(_ subIndexer: RuntimeObjCInterfaceIndexer) { - _subIndexers.withLock { $0.append(subIndexer) } - } -} - -// MARK: - Events - -public enum RuntimeObjCInterfaceEvents { - public struct Event: Sendable { - public enum Kind: Sendable { - case subclassIndexed(className: String, superclass: String, imagePath: String) - case conformanceIndexed(className: String, protocolName: String, imagePath: String) - case categoryConformanceIndexed(targetClassName: String, protocolName: String, imagePath: String) - } - - public let kind: Kind - - public init(kind: Kind) { - self.kind = kind - } - } - - public typealias Handler = @Sendable (Event) -> Void -} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift index 3b92e1cd..7cb0fba2 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Indexing/RuntimeSwiftInterfaceIndexer.swift @@ -13,8 +13,8 @@ import SwiftStdlibToolbox /// upstream `MachOSwiftSection` `SwiftDeclarationIndexer` that layers on the /// relationship reverse tables backing the Inspector's Relationships tab. /// -/// This is the Swift counterpart of `RuntimeObjCInterfaceIndexer`, but the -/// two are not built the same way. On the ObjC side `RuntimeObjCInterfaceIndexer` +/// This is the Swift counterpart of `ObjCInterfaceIndexer`, but the +/// two are not built the same way. On the ObjC side `ObjCInterfaceIndexer` /// *is* the indexer — it parses `MachOObjCSection` / `ObjCDump` itself. On the /// Swift side the heavy parsing is already done by the upstream /// `SwiftDeclarationIndexer`, which we neither own nor can extend; so this type @@ -43,19 +43,19 @@ import SwiftStdlibToolbox /// `typeName(forMangledName:)`) fan out across `self` plus every registered /// sub-indexer — so a query against the `RuntimeSwiftSectionFactory` /// aggregate, which holds every per-image indexer, spans all loaded images. -/// Mirrors `RuntimeObjCInterfaceIndexer`. +/// Mirrors `ObjCInterfaceIndexer`. /// /// `@unchecked Sendable`: the `MachOImage` and `SwiftDeclaration.TypeName` /// values held here are not themselves `Sendable`, but `machO` / `upstream` /// are immutable `let`s and the reverse tables plus `subIndexers` are all -/// `@Mutex`-guarded — mirroring `RuntimeObjCInterfaceIndexer`. +/// `@Mutex`-guarded — mirroring `ObjCInterfaceIndexer`. @dynamicMemberLookup final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { // MARK: - Indexed Image /// The Mach-O image this indexer parses. Bound at `init`, never - /// reassigned — mirrors `RuntimeObjCInterfaceIndexer`, which likewise + /// reassigned — mirrors `ObjCInterfaceIndexer`, which likewise /// binds its `MachOImage` at construction. private let machO: MachOImage @@ -107,7 +107,7 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { /// section's own indexer; on the `RuntimeSwiftSectionFactory` aggregate it /// holds every loaded image's indexer, so the query methods fan out across /// all of them. `@Mutex`-guarded because the factory keeps registering as - /// images load. Mirrors `RuntimeObjCInterfaceIndexer.subIndexers`. + /// images load. Mirrors `ObjCInterfaceIndexer.subIndexers`. @Mutex private var subIndexers: [RuntimeSwiftInterfaceIndexer] = [] @@ -115,7 +115,7 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { /// `machO` is bound here and never changes; `eventHandlers` is forwarded /// straight to the upstream indexer (`RuntimeSwiftSection` builds the - /// progress-event handler). Mirrors `RuntimeObjCInterfaceIndexer.init`, + /// progress-event handler). Mirrors `ObjCInterfaceIndexer.init`, /// where the image is likewise bound at construction. init(machO: MachOImage, eventHandlers: [SwiftIndexEvents.Handler] = []) { self.machO = machO @@ -144,7 +144,7 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { // Build into locals, then assign through the `@Mutex` once each — so // no lock is held across the `await mangleAsString` suspension points. - // Mirrors `RuntimeObjCInterfaceIndexer.prepare()`. + // Mirrors `ObjCInterfaceIndexer.prepare()`. var subclassTable: [String: OrderedSet] = [:] var typeNameTable: [String: SwiftDeclaration.TypeName] = [:] var protocolNameTable: [String: SwiftDeclaration.ProtocolName] = [:] @@ -276,7 +276,7 @@ final class RuntimeSwiftInterfaceIndexer: @unchecked Sendable { /// sub-indexer's `upstream` to `upstream.addSubIndexer` so the upstream's /// own cross-image lookups (`allAllTypeDefinitions`, …) see it too. /// Callers pass `RuntimeSwiftInterfaceIndexer` values and never reach for - /// `.upstream`. Mirrors `RuntimeObjCInterfaceIndexer.addSubIndexer(_:)`; + /// `.upstream`. Mirrors `ObjCInterfaceIndexer.addSubIndexer(_:)`; /// `RuntimeSwiftSectionFactory` calls it as each section is created. func addSubIndexer(_ subIndexer: RuntimeSwiftInterfaceIndexer) { upstream.addSubIndexer(subIndexer.upstream) diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeObjCRelationshipIndex.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeObjCRelationshipIndex.swift new file mode 100644 index 00000000..84df6b3e --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeObjCRelationshipIndex.swift @@ -0,0 +1,182 @@ +import Foundation +import ObjCIndexing +import OrderedCollections + +/// An Objective-C class — or a Swift class carrying an Objective-C ancestor — +/// found to subclass another class or to adopt a protocol. +/// +/// `isSwiftStable` is the structural signal (`class_t`'s `FAST_IS_SWIFT_STABLE` +/// bit) that lets `RuntimeRelationshipsResolver` decide whether to materialize +/// the reference as a Swift `RuntimeObject` or an Objective-C one. The library +/// reports the bit; the domain judgement is made here. +/// +/// Deliberately neither `public` nor `Codable`: relationship references never +/// leave the process. What crosses the XPC boundary is the already-materialized +/// `RuntimeObject`. +struct RuntimeObjCClassReference: Hashable, Sendable { + let className: String + let imagePath: String + let isSwiftStable: Bool +} + +/// Per-image Objective-C relationship index: the inheritance and +/// protocol-adoption reverse tables backing the Inspector's Relationships pane. +/// +/// MachOObjCSection 0003 removed these tables from `ObjCIndexing` — the library +/// parses and broadcasts what it finds, and no longer remembers how classes +/// relate. This is where those broadcasts become tables again. +/// +/// ## Relationship data arrives only through the event stream +/// +/// An instance is installed as the indexer's `eventHandler` *before* +/// `prepare()` runs and accumulates throughout the walk. An `ObjCInterfaceIndexer` +/// constructed without a handler keeps no relationship data at all, so +/// `RuntimeObjCSection` installs one unconditionally — never gated on whether a +/// progress stream happens to exist. +/// +/// ## Tables are built on first query +/// +/// `record(_:)` only appends; the tables are materialized lazily. There is +/// deliberately no "sealing" call the caller must remember to make: forgetting +/// it, or skipping it because `prepare()` threw, would leave the index +/// permanently empty *without any error* — the same silent-emptiness failure +/// this whole design exists to rule out. `prewarm()` is available to pay the +/// build cost up front, and is never required for correctness. +/// +/// ## Equivalence with the library's former tables +/// +/// Three properties of the old implementation are reproduced deliberately: +/// +/// 1. Inline adoptions and category-contributed adoptions land in **one** +/// conformer table, so a single query returns both. +/// 2. Events are replayed from a **single** queue in arrival order, so inline +/// adoptions precede category ones exactly as they did when the library +/// walked classes before categories. +/// 3. `OrderedSet` deduplicates on all three fields, not on `className`. A class +/// can legitimately appear twice for one protocol with differing +/// `isSwiftStable` — inline adoption reads the class' own flag while a +/// category resolves its target across images and falls back to `false`. +/// Collapsing those would look like a bug fix and would be a behaviour change. +/// +/// `@unchecked Sendable`: every stored property is guarded by `lock`. +final class RuntimeObjCRelationshipIndex: @unchecked Sendable { + private struct Tables { + var subclassesByClassName: [String: OrderedSet] = [:] + var conformingClassesByProtocolName: [String: OrderedSet] = [:] + } + + private let lock = NSLock() + + /// Events in arrival order, awaiting replay. Cleared once `tables` is built. + private var pendingEvents: [ObjCIndexingEvent] = [] + + private var tables: Tables? + + init() {} + + // MARK: - Accumulation + + /// Append one event. Progress events are ignored; only the three + /// relationship cases carry table data. + /// + /// A single `append` under the lock is all the work done on the parse hot + /// path — cheaper than the two dictionary lookups plus `OrderedSet.append` + /// the library used to perform here. The lock is required: the library + /// promises the *order* of events but explicitly not the thread they arrive + /// on, leaving room to parallelize the walk later. + func record(_ event: ObjCIndexingEvent) { + switch event { + case .progress: + return + case .subclassIndexed, .conformanceIndexed, .categoryConformanceIndexed: + lock.lock() + defer { lock.unlock() } + if tables != nil { + // Already materialized — fold the event straight in rather than + // invalidating, since `pendingEvents` was released at build time + // and could no longer reproduce the earlier events. Arrival + // order still holds: this event genuinely comes last. + apply(event, into: &tables!) + } else { + pendingEvents.append(event) + } + } + } + + /// Build the tables now rather than on first query. Optional: skipping it + /// costs nothing but deferring the same work. + func prewarm() { + lock.lock() + defer { lock.unlock() } + _ = materializedTables() + } + + // MARK: - Query + + /// Direct subclasses of `className` recorded for this image, in the order + /// the library walked `__objc_classlist`. + func subclasses(of className: String) -> [RuntimeObjCClassReference] { + lock.lock() + defer { lock.unlock() } + return Array(materializedTables().subclassesByClassName[className] ?? []) + } + + /// Classes adopting `protocolName` in this image — inline adoptions first, + /// then those contributed by categories. + func conformingClasses(toProtocol protocolName: String) -> [RuntimeObjCClassReference] { + lock.lock() + defer { lock.unlock() } + return Array(materializedTables().conformingClassesByProtocolName[protocolName] ?? []) + } + + // MARK: - Materialization + + /// Caller must hold `lock`. + private func materializedTables() -> Tables { + if let tables { return tables } + + var built = Tables() + for event in pendingEvents { + apply(event, into: &built) + } + + tables = built + pendingEvents = [] + return built + } + + /// Fold one event into `tables`. Caller must hold `lock`. + private func apply(_ event: ObjCIndexingEvent, into tables: inout Tables) { + switch event { + case .progress: + return + + case .subclassIndexed(let className, let superclass, let imagePath, let isSwiftStable): + let reference = RuntimeObjCClassReference( + className: className, + imagePath: imagePath, + isSwiftStable: isSwiftStable + ) + tables.subclassesByClassName[superclass, default: []].append(reference) + + case .conformanceIndexed(let className, let protocolName, let imagePath, let isSwiftStable): + let reference = RuntimeObjCClassReference( + className: className, + imagePath: imagePath, + isSwiftStable: isSwiftStable + ) + tables.conformingClassesByProtocolName[protocolName, default: []].append(reference) + + case .categoryConformanceIndexed(let targetClassName, let protocolName, let imagePath, let targetIsSwiftStable): + // `imagePath` is the image declaring the *category*, not the one + // declaring `targetClassName`. Preserved verbatim: it is what the + // library's table recorded, and changing it is a behaviour change. + let reference = RuntimeObjCClassReference( + className: targetClassName, + imagePath: imagePath, + isSwiftStable: targetIsSwiftStable + ) + tables.conformingClassesByProtocolName[protocolName, default: []].append(reference) + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift index effc32e8..66935023 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Relationships/RuntimeRelationshipsResolver.swift @@ -58,8 +58,8 @@ actor RuntimeRelationshipsResolver { // // For ObjC class/protocol targets, `object.name` is the raw ObjC // class/protocol name (the same string used as the key in - // `RuntimeObjCInterfaceIndexer.classes`/`.protocols` and as the - // `superclassByClassName` key in `RuntimeObjCInterfaceIndexer`). + // `ObjCInterfaceIndexer.classes`/`.protocols` and as the + // `superclassByClassName` key in `ObjCInterfaceIndexer`). // // For Swift class targets, `object.name` is the mangled string // produced by `mangleAsString(typeName.node)`, which is the @@ -82,7 +82,7 @@ actor RuntimeRelationshipsResolver { if wantsSubclasses { if let objcKey { if let objcSection = await objcSectionFactory.existingSection(for: imagePath) { - for reference in objcSection.objcIndexer.subclasses(of: objcKey) { + for reference in objcSection.objcRelationshipIndex.subclasses(of: objcKey) { if let runtimeObject = await materializeRelationshipReference(reference) { subclasses.append(runtimeObject) } @@ -103,7 +103,7 @@ actor RuntimeRelationshipsResolver { if wantsConformers { if isObjCProtocol { if let objcSection = await objcSectionFactory.existingSection(for: imagePath) { - for reference in objcSection.objcIndexer.conformingClasses(toProtocol: object.name) { + for reference in objcSection.objcRelationshipIndex.conformingClasses(toProtocol: object.name) { if let runtimeObject = await materializeRelationshipReference(reference) { conformers.append(runtimeObject) } @@ -161,7 +161,7 @@ actor RuntimeRelationshipsResolver { /// section. When that lookup fails (e.g. an `@objc(customName)` class /// whose raw name isn't a Swift mangling), the entry is dropped rather /// than fall back to `.objc(.type(.class))`. - private func materializeRelationshipReference(_ reference: ObjCClassReference) async -> RuntimeObject? { + private func materializeRelationshipReference(_ reference: RuntimeObjCClassReference) async -> RuntimeObject? { if reference.isSwiftStable { // `demangleAsNode` / `mangleAsString` each ship a sync and an async // overload; the compiler picks the async one inside this `async` diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+CType.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+CType.swift deleted file mode 100644 index b84dc013..00000000 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+CType.swift +++ /dev/null @@ -1,210 +0,0 @@ -import Foundation -import MetaCodable -public import Semantic - -// MARK: - C Type Transformer Module - -extension Transformer { - /// Replaces C primitive types with custom types. - /// - /// Example: - /// ```swift - /// var module = Transformer.CType() - /// module.isEnabled = true - /// module.replacements[.double] = "CGFloat" - /// module.replacements[.longLong] = "NSInteger" - /// ``` - @Codable - public struct CType: Module { - public typealias Parameter = Pattern - public typealias Input = SemanticString - public typealias Output = SemanticString - - public static let displayName = "C Type Replacement" - - @Default(ifMissing: false) - public var isEnabled: Bool - - @Default(ifMissing: [:]) - public var replacements: [Pattern: String] - - public init(isEnabled: Bool = false, replacements: [Pattern: String] = [:]) { - self.isEnabled = isEnabled - self.replacements = replacements - } - - public func transform(_ input: SemanticString) -> SemanticString { - let sorted = sortedReplacements - guard !sorted.isEmpty else { return input } - - let components = input.components - guard !components.isEmpty else { return input } - - var result: [AtomicComponent] = [] - var index = 0 - - while index < components.count { - if let (replacement, consumed) = match(in: components, at: index, patterns: sorted) { - result.append(AtomicComponent(string: replacement, type: .type(.other, .name))) - index += consumed - } else { - result.append(components[index]) - index += 1 - } - } - - return SemanticString(components: result) - } - - // Sorted by pattern length (longest first) - private var sortedReplacements: [(Pattern, String)] { - replacements - .filter { !$0.value.isEmpty } - .sorted { $0.key.keywords.count > $1.key.keywords.count } - .map { ($0.key, $0.value) } - } - - private func match( - in components: [AtomicComponent], - at startIndex: Int, - patterns: [(Pattern, String)] - ) -> (String, Int)? { - for (pattern, replacement) in patterns { - if let consumed = matchKeywords(pattern.keywords, in: components, at: startIndex) { - return (replacement, consumed) - } - } - return nil - } - - private func matchKeywords( - _ keywords: [String], - in components: [AtomicComponent], - at startIndex: Int - ) -> Int? { - guard !keywords.isEmpty else { return nil } - - var ci = startIndex - var ki = 0 - var consumed = 0 - - while ki < keywords.count && ci < components.count { - let c = components[ci] - - // Skip whitespace - if c.type == .standard && c.string.allSatisfy(\.isWhitespace) { - ci += 1 - consumed += 1 - continue - } - - guard c.type == .keyword, c.string == keywords[ki] else { return nil } - - ki += 1 - ci += 1 - consumed += 1 - } - - return ki == keywords.count ? consumed : nil - } - } -} - -// MARK: - Pattern - -extension Transformer.CType { - /// C primitive type patterns. - public enum Pattern: String, CaseIterable, Codable, Sendable, Hashable { - case char - case uchar - case short - case ushort - case int - case uint - case long - case ulong - case longLong - case ulongLong - case float - case double - case longDouble - - public var displayName: String { - switch self { - case .char: "char" - case .uchar: "unsigned char" - case .short: "short" - case .ushort: "unsigned short" - case .int: "int" - case .uint: "unsigned int" - case .long: "long" - case .ulong: "unsigned long" - case .longLong: "long long" - case .ulongLong: "unsigned long long" - case .float: "float" - case .double: "double" - case .longDouble: "long double" - } - } - - var keywords: [String] { - switch self { - case .char: ["char"] - case .uchar: ["unsigned", "char"] - case .short: ["short"] - case .ushort: ["unsigned", "short"] - case .int: ["int"] - case .uint: ["unsigned", "int"] - case .long: ["long"] - case .ulong: ["unsigned", "long"] - case .longLong: ["long", "long"] - case .ulongLong: ["unsigned", "long", "long"] - case .float: ["float"] - case .double: ["double"] - case .longDouble: ["long", "double"] - } - } - } -} - -// MARK: - Presets - -extension Transformer.CType { - public enum Presets { - public static let stdint: [Pattern: String] = [ - .uchar: "uint8_t", - .ushort: "uint16_t", - .uint: "uint32_t", - .ulong: "uint64_t", - .ulongLong: "uint64_t", - - .char: "int8_t", - .short: "int16_t", - .int: "int32_t", - .long: "int64_t", - .longLong: "int64_t", - ] - - public static let foundation: [Pattern: String] = [ - .double: "CGFloat", - .long: "NSInteger", - .ulong: "NSUInteger", - .longLong: "NSInteger", - .ulongLong: "NSUInteger", - ] - - public static let mixed: [Pattern: String] = [ - .uchar: "uint8_t", - .ushort: "uint16_t", - .uint: "uint32_t", - .char: "int8_t", - .short: "int16_t", - .int: "int32_t", - .long: "NSInteger", - .ulong: "NSUInteger", - .longLong: "NSInteger", - .ulongLong: "NSUInteger", - .double: "CGFloat", - ] - } -} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+Configuration.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+Configuration.swift new file mode 100644 index 00000000..a4cb308b --- /dev/null +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+Configuration.swift @@ -0,0 +1,43 @@ +import Foundation +public import OutputTransformer +public import ObjCOutputTransformer +public import SwiftOutputTransformer + +// MARK: - Aggregated Configuration + +extension Transformer { + /// Aggregated configuration for every transformer module, used for + /// persistence. + /// + /// This lives here rather than library-side because it is the only place + /// that spans both halves: the ObjC modules ship with MachOObjCSection and + /// the Swift ones with MachOSwiftSection, and nothing but RuntimeViewer + /// needs to persist and edit them as a single unit. + public struct Configuration: Sendable, Equatable, Hashable, Codable { + public var objc: Transformer.ObjCConfiguration + public var swift: Transformer.SwiftConfiguration + + public init( + objc: Transformer.ObjCConfiguration = .init(), + swift: Transformer.SwiftConfiguration = .init() + ) { + self.objc = objc + self.swift = swift + } + + // Missing-key-tolerant decoding (compatible with the previous + // MetaCodable `@Default(_:)` persistence). + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.objc = try container.decodeIfPresent(ObjCConfiguration.self, forKey: .objc) ?? .init() + self.swift = try container.decodeIfPresent(SwiftConfiguration.self, forKey: .swift) ?? .init() + } + + public static let `default` = Self() + + /// Whether any module is enabled. + public var hasEnabledModules: Bool { + objc.hasEnabledModules || swift.hasEnabledModules + } + } +} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+ObjCIvarOffset.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+ObjCIvarOffset.swift deleted file mode 100644 index 33f4a72c..00000000 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer+ObjCIvarOffset.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -import MetaCodable - -// MARK: - ObjC Ivar Offset Transformer Module - -extension Transformer { - /// Customizes ObjC ivar offset comment format using token templates. - @Codable - public struct ObjCIvarOffset: Module { - public typealias Parameter = Token - public typealias Output = String - - public static let displayName = "ObjC Ivar Offset Comment" - - @Default(ifMissing: false) - public var isEnabled: Bool - - @Default(ifMissing: Templates.standard) - public var template: String - - @Default(ifMissing: true) - public var useHexadecimal: Bool - - public init(isEnabled: Bool = false, template: String = Templates.standard, useHexadecimal: Bool = true) { - self.isEnabled = isEnabled - self.template = template - self.useHexadecimal = useHexadecimal - } - - public func transform(_ input: Input) -> String { - template - .replacingOccurrences(of: Token.offset.placeholder, with: formatValue(input.offset)) - } - - private func formatValue(_ value: Int) -> String { - useHexadecimal ? "0x\(String(value, radix: 16, uppercase: true))" : String(value) - } - - public func contains(_ token: Token) -> Bool { - template.contains(token.placeholder) - } - } -} - -// MARK: - Input - -extension Transformer.ObjCIvarOffset { - public struct Input: Sendable { - public let offset: Int - - public init(offset: Int) { - self.offset = offset - } - } -} - -// MARK: - Token - -extension Transformer.ObjCIvarOffset { - public enum Token: String, CaseIterable, Sendable { - case offset - - public var placeholder: String { "${\(rawValue)}" } - - public var displayName: String { - switch self { - case .offset: "Offset" - } - } - } -} - -// MARK: - Templates - -extension Transformer.ObjCIvarOffset { - public enum Templates { - public static let standard = "offset: ${offset}" - public static let labeled = "ivar offset: ${offset}" - public static let bare = "${offset}" - - public static let all: [(name: String, template: String)] = [ - ("Standard", standard), - ("Labeled", labeled), - ("Bare", bare), - ] - } -} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer.swift index 664d4b00..7bc6a1cb 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Transformer/Transformer.swift @@ -1,54 +1,15 @@ -import Foundation -import MetaCodable - -/// The Swift-side transformer template mechanism (the `Transformer` namespace: -/// comment token templates, presets, the `Module` protocol, and -/// `SwiftConfiguration`) moved library-side into MachOSwiftSection's -/// `OutputTransformer` module, so the templates render inside the library -/// and RuntimeViewer keeps only the settings UI. This re-export keeps every -/// existing `Transformer.…` reference compiling unchanged. +/// The `Transformer` namespace and its `Module` protocol come from +/// swift-semantic-string, while the concrete modules ship with the library +/// that owns their vocabulary — `CType` / `ObjCIvarOffset` with +/// MachOObjCSection, the Swift comment kinds with MachOSwiftSection. All of +/// them extend the same namespace. /// -/// The ObjC-side modules (`CType`, `ObjCIvarOffset`) and the aggregate -/// persistence `Configuration` remain here for now (declared as extensions of -/// the imported namespace), pending a library-side home for the ObjC -/// rendering pipeline. +/// Only the aggregate `Transformer.Configuration` remains here (see +/// `Transformer+Configuration.swift`): it is the one piece that spans both +/// halves, and nothing outside RuntimeViewer needs to persist them as a unit. +/// +/// These re-exports keep every existing `Transformer.…` reference in +/// RuntimeViewer compiling unchanged. @_exported import OutputTransformer - -// MARK: - ObjC Configuration - -extension Transformer { - /// Configuration for ObjC-specific transformer modules. - @Codable - public struct ObjCConfiguration: Sendable, Equatable, Hashable { - @Default(ifMissing: Transformer.CType()) - public var cType: Transformer.CType - @Default(ifMissing: Transformer.ObjCIvarOffset()) - public var ivarOffset: Transformer.ObjCIvarOffset - - public init(cType: CType = .init(), ivarOffset: ObjCIvarOffset = .init()) { - self.cType = cType - self.ivarOffset = ivarOffset - } - } -} - -// MARK: - Aggregated Configuration - -extension Transformer { - /// Aggregated configuration for all transformer modules (used for persistence). - @Codable - @MemberInit - public struct Configuration: Sendable, Equatable, Hashable { - @Default(Transformer.ObjCConfiguration()) - public var objc: Transformer.ObjCConfiguration - @Default(Transformer.SwiftConfiguration()) - public var swift: Transformer.SwiftConfiguration - - public static let `default` = Self() - - /// Whether any module is enabled. - public var hasEnabledModules: Bool { - objc.cType.isEnabled || objc.ivarOffset.isEnabled || swift.hasEnabledModules - } - } -} +@_exported import ObjCOutputTransformer +@_exported import SwiftOutputTransformer diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/MachOImage+AddressFormatting.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/MachOImage+AddressFormatting.swift deleted file mode 100644 index 4e3c7a0c..00000000 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/MachOImage+AddressFormatting.swift +++ /dev/null @@ -1,44 +0,0 @@ -import MachOKit -import MachOExtensions -import Semantic - -extension MachOImage { - /// Safely format an IMP address into a resolved virtual address string. - /// - /// Returns `nil` when the raw value is zero or falls below the image base, - /// meaning the caller should treat it as invalid. - func formattedAddress(forRawValue rawValue: UInt64) -> String? { - let value = UInt(rawValue) - let baseAddress = UInt(bitPattern: ptr) - guard value != 0, value >= baseAddress else { - return nil - } - return "0x\(addressString(forOffset: .init(value &- baseAddress)))" - } - - /// Build a ``Comment`` component for an IMP address. - /// - /// Produces a normal comment (e.g. `// IMP: 0x1A2B3C`) when the address - /// is valid, or an ``Error`` component (e.g. `// IMP: `) - /// when it is not. - @SemanticStringBuilder - func impAddressComment(label: String, rawValue: UInt64) -> SemanticString { - if let resolved = formattedAddress(forRawValue: rawValue) { - Comment("\(label): \(resolved)") - } else { - Comment("\(label): ") - Error("") - } - } - - /// Format an IMP address into a plain string suitable for data models. - /// - /// Returns a resolved virtual address when valid, or a raw hex - /// representation prefixed with `` when not. - func formattedAddressString(forRawValue rawValue: UInt64) -> String { - if let resolved = formattedAddress(forRawValue: rawValue) { - return resolved - } - return "" - } -} diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/ObjCDump+SemanticString.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/ObjCDump+SemanticString.swift deleted file mode 100644 index 77639825..00000000 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Utils/ObjCDump+SemanticString.swift +++ /dev/null @@ -1,896 +0,0 @@ -import Foundation -import MachOKit -import MachOExtensions -import Semantic -import ObjCDump -import ObjCTypeDecodeKit -import MemberwiseInit - -@MemberwiseInit() -final class ObjCDumpContext { - let machO: MachOImage - var options: ObjCGenerationOptions - var cTypeReplacements: [Transformer.CType.Pattern: String] = [:] - var ivarOffsetTransformer: Transformer.ObjCIvarOffset? - var currentArray: SemanticString? - var methodIMPs: [String: UInt64] = [:] - var classMethodIMPs: [String: UInt64] = [:] - var isExpandHandler: (_ name: String?, _ isStruct: Bool) -> Bool = { _, _ in true } -} - -extension ObjCClassInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - Keyword("@interface") - Space() - TypeDeclaration(kind: .class, name) - - if let superClassName { - " : " - TypeName(kind: .class, superClassName) - } - - Joined(separator: ", ", prefix: " <", suffix: ">") { - for `protocol` in protocols { - TypeName(kind: .protocol, `protocol`.name) - } - } - - Joined { - MemberList(level: 1) { - for ivar in ivars { - ivar.semanticString(using: context) - } - } - } prefix: { - Space() - "{" - } suffix: { - "}" - } - - BreakLine() - - Joined(suffix: BreakLine()) { - BlockList { - for property in classProperties { - property.semanticString(using: context) - } - } - BlockList { - for property in properties { - property.semanticString(using: context) - } - } - BlockList { - for method in classMethods { - method.semanticString(using: context) - } - } - BlockList { - for method in methods { - method.semanticString(using: context) - } - } - } - - Keyword("@end") - } -} - -extension ObjCProtocolInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - Keyword("@protocol") - Space() - TypeDeclaration(kind: .protocol, name) - - Joined(separator: ", ", prefix: " <", suffix: ">") { - for `protocol` in protocols { - TypeName(kind: .protocol, `protocol`.name) - } - } - - BreakLine() - - Joined(separator: BreakLine(), prefix: BreakLine(), suffix: BreakLine()) { - Joined { - BlockList { - for property in classProperties { - property.semanticString(using: context) - } - } - BlockList { - for property in properties { - property.semanticString(using: context) - } - } - BlockList { - for method in classMethods { - method.semanticString(using: context) - } - } - BlockList { - for method in methods { - method.semanticString(using: context) - } - } - } prefix: { - Keyword("@required") - BreakLine() - } - - Joined { - BlockList { - for property in optionalClassProperties { - property.semanticString(using: context) - } - } - BlockList { - for property in optionalProperties { - property.semanticString(using: context) - } - } - BlockList { - for method in optionalClassMethods { - method.semanticString(using: context) - } - } - BlockList { - for method in optionalMethods { - method.semanticString(using: context) - } - } - } prefix: { - Keyword("@optional") - BreakLine() - } - } - - Keyword("@end") - } -} - -extension ObjCCategoryInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - Keyword("@interface") - Space() - TypeName(kind: .class, className) - Space() - "(\(name))" - - Joined(separator: ", ", prefix: " <", suffix: ">") { - for `protocol` in protocols { - TypeName(kind: .protocol, `protocol`.name) - } - } - - BreakLine() - - Joined(suffix: BreakLine()) { - BlockList { - for property in classProperties { - property.semanticString(using: context) - } - } - - BlockList { - for property in properties { - property.semanticString(using: context) - } - } - - BlockList { - for method in classMethods { - method.semanticString(using: context) - } - } - - BlockList { - for method in methods { - method.semanticString(using: context) - } - } - } - - Keyword("@end") - } -} - -extension ObjCIvarInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - if let type, case .bitField(let width) = type { - ObjCField(type: .int, name: name, bitWidth: width) - .semanticString(fallbackName: name, context: context) - } else { - if [.char, .uchar].contains(type) { - Keyword("BOOL") - Space() - Variable(name) - ";" - } else { - if let type = type?.semanticDecoded(context: context) { - type - if type.string.last != "*" { - Space() - } - Variable(name) - if let currentArray = context.currentArray { - currentArray - context.currentArray = nil - } - ";" - } else { - UnknownError() - Space() - Variable(name) - ";" - } - } - } - - if context.options.addIvarOffsetComments { - Space() - if let ivarOffsetTransformer = context.ivarOffsetTransformer { - Comment(ivarOffsetTransformer.transform(.init(offset: offset))) - } else { - Comment("offset: \(offset)") - } - } - } -} - -extension ObjCPropertyInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - Keyword("@property") - - Joined(separator: ", ", prefix: " (", suffix: ")") { - if attributes.contains(.nonatomic) { - Keyword("nonatomic") - } - - if attributes.contains(.weak) { - Keyword("weak") - } - - if attributes.contains(.copy) { - Keyword("copy") - } - - if attributes.contains(.retain) { - Keyword("strong") - } - - if isClassProperty { - Keyword("class") - } - - if let getter = attributes.compactMap(\.getter).first { - Group { - Keyword("getter") - "=" - getter - } - } - - if let setter = attributes.compactMap(\.setter).first { - Group { - Keyword("setter") - "=" - setter - } - } - - if attributes.contains(.readonly) { - Keyword("readonly") - } - } - - Space() - - let typeString = attributes.compactMap(\.type).first?.semanticDecodedForArgument(context: context) - - if let typeString { - typeString - if typeString.string.last != "*" { - Space() - } - } else { - UnknownError() - Space() - } - - MemberDeclaration(name) - ";" - - if context.options.addPropertyAttributesComments { - Joined(separator: " ", prefix: " ") { - if attributes.contains(.dynamic) { - Comment("@dynamic \(name)") - } - - if let ivar { - if ivar == name { - Comment("@synthesize \(ivar)") - } else { - Comment("@synthesize \(name) = \(ivar)") - } - } - } - } - - if context.options.addPropertyAccessorAddressComments { - let imps = isClassProperty ? context.classMethodIMPs : context.methodIMPs - let getterName = customGetter ?? name - let setterName = customSetter ?? "set\(name.uppercasedFirst):" - - Joined(separator: " ", prefix: " ") { - if let getterIMP = imps[getterName] { - context.machO.impAddressComment(label: "getter IMP", rawValue: getterIMP) - } - if let setterIMP = imps[setterName] { - context.machO.impAddressComment(label: "setter IMP", rawValue: setterIMP) - } - } - } - } -} - -extension ObjCMethodInfo { - @SemanticStringBuilder - func semanticString(using context: ObjCDumpContext) -> SemanticString { - if isClassMethod { - "+" - } else { - "-" - } - - Space() - - "(" - if let returnType = type?.returnType { - returnType.semanticDecodedForArgument(context: context) - } else { - UnknownError() - } - ")" - - let numberOfArguments = name.filter { $0 == ":" }.count - - if numberOfArguments == 0 { - FunctionDeclaration(name) - } else { - let nameAndLabels = name.split(separator: ":") - let argumentInfos = type?.argumentInfos ?? [] - - for (index, label) in nameAndLabels.enumerated() { - if index > 0 { - Space() - } - let labelString = String(label) - FunctionDeclaration(labelString) - ":" - "(" - if index < argumentInfos.count { - argumentInfos[index].type.semanticDecodedForArgument(context: context) - } else { - UnknownError() - } - ")" - Argument(NamingIntelligent.parameterName(from: labelString)) - } - } - - ";" - - if context.options.addMethodIMPAddressComments { - Space() - context.machO.impAddressComment(label: "IMP", rawValue: imp) - } - } -} - -extension ObjCField { - @SemanticStringBuilder - func semanticString(fallbackName: String, level: Int = 1, context: ObjCDumpContext) -> SemanticString { - type.semanticDecoded(level: level, context: context) - Space() - Variable(name ?? fallbackName) - if let bitWidth { - " : " - Numeric(bitWidth) - } - ";" - } -} - -extension ObjCModifier { - @SemanticStringBuilder - func semanticDecoded(level: Int = 1) -> SemanticString { - switch self { - case .complex: - Keyword("_Complex") - case .atomic: - Keyword("_Atomic") - case .const: - Keyword("const") - case .in: - Keyword("in") - case .inout: - Keyword("inout") - case .out: - Keyword("out") - case .bycopy: - Keyword("bycopy") - case .byref: - Keyword("byref") - case .oneway: - Keyword("oneway") - case .register: - Keyword("register") - } - } -} - -extension ObjCType { - @SemanticStringBuilder - func semanticDecodedForArgument(context: ObjCDumpContext) -> SemanticString { - switch self { - case .struct(let name, let fields), - .union(let name, let fields): - Keyword(isStruct ? "struct" : "union") - if let name { - Space() - TypeName(kind: isStruct ? .struct : .other, name) - } - - if context.isExpandHandler(name, isStruct) { - Joined { - if let fields { - Joined(separator: " ") { - for (index, field) in fields.enumerated() { - Group { - field.type.semanticDecodedForArgument(context: context) - Space() - Variable(field.name ?? "x\(index)") - if let bitWidth = field.bitWidth { - " : " - Numeric(bitWidth) - } - ";" - } - } - } - } - } prefix: { - " { " - } suffix: { - " }" - } - } - case .char: - Keyword("BOOL") - case .pointer(let type): - type.semanticDecodedForArgument(context: context) - Space() - "*" - case .modified(let modifier, let type): - modifier.semanticDecoded(level: 0) - Space() - type.semanticDecodedForArgument(context: context) - default: - semanticDecoded(level: 0, context: context) - } - } - - @SemanticStringBuilder - func semanticDecoded(level: Int = 1, context: ObjCDumpContext) -> SemanticString { - switch self { - case .class: - TypeName(kind: .class, "Class") - case .selector: - Keyword("SEL") - case .char: - if let r = context.cTypeReplacements[.char] { TypeName(kind: .other, r) } else { Keyword("char") } - case .uchar: - if let r = context.cTypeReplacements[.uchar] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("unsigned") - Keyword("char") - } - } - case .short: - if let r = context.cTypeReplacements[.short] { TypeName(kind: .other, r) } else { Keyword("short") } - case .ushort: - if let r = context.cTypeReplacements[.ushort] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("unsigned") - Keyword("short") - } - } - case .int: - if let r = context.cTypeReplacements[.int] { TypeName(kind: .other, r) } else { Keyword("int") } - case .uint: - if let r = context.cTypeReplacements[.uint] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("unsigned") - Keyword("int") - } - } - case .long: - if let r = context.cTypeReplacements[.long] { TypeName(kind: .other, r) } else { Keyword("long") } - case .ulong: - if let r = context.cTypeReplacements[.ulong] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("unsigned") - Keyword("long") - } - } - case .longLong: - if let r = context.cTypeReplacements[.longLong] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("long") - Keyword("long") - } - } - case .ulongLong: - if let r = context.cTypeReplacements[.ulongLong] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("unsigned") - Keyword("long") - Keyword("long") - } - } - case .int128: - TypeName(kind: .other, "__int128_t") - case .uint128: - TypeName(kind: .other, "__uint128_t") - case .float: - if let r = context.cTypeReplacements[.float] { TypeName(kind: .other, r) } else { Keyword("float") } - case .double: - if let r = context.cTypeReplacements[.double] { TypeName(kind: .other, r) } else { Keyword("double") } - case .longDouble: - if let r = context.cTypeReplacements[.longDouble] { - TypeName(kind: .other, r) - } else { - Joined(separator: Space()) { - Keyword("long") - Keyword("double") - } - } - case .bool: - Keyword("BOOL") - case .void: - Keyword("void") - case .unknown: - UnknownError() - case .charPtr: - Keyword("char") - Space() - "*" - case .atom: - Keyword("atom") - case .object(let name): - if let name { - // eg. id - if name.first == "<" && name.last == ">" { - let components = name.components(separatedBy: "><") - Keyword("id") - if components.count > 1 { - Joined(separator: ", ", prefix: "<", suffix: ">") { - for (offset, component) in components.offsetEnumerated() { - if offset.isStart { - TypeName(kind: .protocol, String(component.dropFirst(1))) - } else if offset.isEnd { - TypeName(kind: .protocol, String(component.dropLast(1))) - } else { - TypeName(kind: .protocol, String(component)) - } - } - } - } else { - "<" - TypeName(kind: .protocol, String(name.dropFirst(1).dropLast(1))) - ">" - } - } else { - // eg. NSObject - if let protocolPrefixIndex = name.firstIndex(of: "<"), let protocolSuffixIndex = name.lastIndex(of: ">") { - let protocolStartIndex = name.index(after: protocolPrefixIndex) - let protocols = name[protocolStartIndex..<") - TypeName(kind: .class, String(name[name.startIndex.. 1 { - Joined(separator: ", ", prefix: "<", suffix: ">") { - for (offset, component) in components.offsetEnumerated() { - if offset.isStart { - TypeName(kind: .protocol, String(component.dropFirst(1))) - } else if offset.isEnd { - TypeName(kind: .protocol, String(component.dropLast(1))) - } else { - TypeName(kind: .protocol, String(component)) - } - } - } - } else { - // eg. NSObject - "<" - TypeName(kind: .protocol, String(protocols)) - ">" - } - Space() - "*" - } else { - TypeName(kind: .class, name) - Space() - "*" - } - } - } else { - Keyword("id") - } - case .block(let ret, let args): - if let ret, let args { - ret.semanticDecoded(level: level, context: context) - " (^)(" - Joined(separator: ", ") { - for arg in args { - arg.semanticDecoded(level: level, context: context) - } - } - ")" - } else { - Keyword("id") - Space() - InlineComment("block") - } - case .functionPointer: - Keyword("void") - Space() - "*" - Space() - InlineComment("function pointer") - case .array(let type, let size): - type.semanticDecoded(level: level, context: context) - context.currentArray = SemanticString { - "[" - if let size { - Numeric(size) - } - "]" - } - case .pointer(let type): - type.semanticDecoded(level: level, context: context) - Space() - "*" - case .bitField(let width): - Keyword("int") - Space() - Variable("x") - " : " - Numeric(width) - case .struct(let name, let fields), - .union(let name, let fields): - Keyword(isStruct ? "struct" : "union") - if let name { - Space() - TypeName(kind: isStruct ? .struct : .other, name) - } - if context.isExpandHandler(name, isStruct) { - Joined { - if let fields { - MemberList(level: level + 1) { - for (index, field) in fields.enumerated() { - field.semanticString(fallbackName: "x\(index)", level: level + 1, context: context) - } - } - } - } prefix: { - " {" - } suffix: { - Indent(level: level) - "}" - }.if(fields != nil || name == nil) - } - case .modified(let modifier, let type): - modifier.semanticDecoded(level: level) - Space() - type.semanticDecoded(level: level, context: context) - case .other(let string): - string - } - } -} - -extension ObjCCategoryInfo { - var uniqueName: String { - "\(className)(\(name))" - } -} - -extension ObjCPropertyInfo { - var ivar: String? { - attributes.compactMap(\.ivar).first - } - - var customGetter: String? { - attributes.compactMap(\.getter).first - } - - var customSetter: String? { - attributes.compactMap(\.setter).first - } -} - -// MARK: - Naming Intelligent - -/// A utility for intelligently guessing parameter names from Objective-C method labels. -/// -/// Examples: -/// - `initWithTitle` -> `title` -/// - `objectForKey` -> `key` -/// - `valueAtIndex` -> `index` -/// - `setFrame` -> `frame` -/// - `setMaximumNumberOfLines` -> `lines` -/// - `name` -> `name` -private enum NamingIntelligent { - /// Common prepositions used in Objective-C method names (lowercase). - /// Ordered by length (longest first) to match longer prepositions before shorter ones. - private static let prepositions: [String] = [ - "withcontentsof", - "byappending", - "byreplacing", - "fromstring", - "tostring", - "containing", - "including", - "excluding", - "replacing", - "returning", - "matching", - "starting", - "between", - "through", - "without", - "within", - "during", - "before", - "behind", - "except", - "under", - "using", - "after", - "about", - "above", - "along", - "among", - "below", - "named", - "called", - "having", - "where", - "until", - "since", - "with", - "from", - "into", - "onto", - "upon", - "over", - "like", - "near", - "past", - "for", - "and", - "but", - "nor", - "yet", - "via", - "per", - "at", - "by", - "in", - "of", - "on", - "to", - "as", - ] - - /// Prefixes that should be stripped before looking for prepositions. - private static let prefixes: [String] = [ - "_set", - "_get", - "set", - "get", - ] - - /// Guesses a parameter name from an Objective-C method label. - /// - /// - Parameter label: The method label (e.g., "initWithTitle", "objectForKey") - /// - Returns: The guessed parameter name (e.g., "title", "key") - static func parameterName(from label: String) -> String { - guard !label.isEmpty else { return "arg" } - - var workingLabel = label - let lowercasedLabel = label.lowercased() - - // First, strip known prefixes like set/get - for prefix in prefixes { - if lowercasedLabel.hasPrefix(prefix) && label.count > prefix.count { - let afterPrefix = label.index(label.startIndex, offsetBy: prefix.count) - // Make sure the next character is uppercase (word boundary) - if label[afterPrefix].isUppercase { - workingLabel = String(label[afterPrefix...]) - break - } - } - } - - // Now search for prepositions from the beginning, find the LAST match - let lowercasedWorking = workingLabel.lowercased() - var lastMatchEnd: String.Index? - - for preposition in prepositions { - // Search for all occurrences from the beginning - var searchStart = lowercasedWorking.startIndex - while let range = lowercasedWorking.range(of: preposition, range: searchStart ..< lowercasedWorking.endIndex) { - // Calculate the corresponding range in the working label - let startDistance = lowercasedWorking.distance(from: lowercasedWorking.startIndex, to: range.lowerBound) - let endDistance = lowercasedWorking.distance(from: lowercasedWorking.startIndex, to: range.upperBound) - let originalStart = workingLabel.index(workingLabel.startIndex, offsetBy: startDistance) - let originalEnd = workingLabel.index(workingLabel.startIndex, offsetBy: endDistance) - - // Check word boundary for camelCase: - // 1. The preposition must start with uppercase (e.g., "With" in "initWithTitle") - // 2. After: must be uppercase letter (the next word starts) - let prepositionStartChar = workingLabel[originalStart] - let startsWithUppercase = prepositionStartChar.isUppercase - - let hasValidEnd: Bool - if originalEnd >= workingLabel.endIndex { - // Preposition at the end of the label is not valid - hasValidEnd = false - } else { - let nextChar = workingLabel[originalEnd] - hasValidEnd = nextChar.isUppercase - } - - if startsWithUppercase && hasValidEnd { - // Use the last (rightmost) preposition match - if lastMatchEnd == nil || originalEnd > lastMatchEnd! { - lastMatchEnd = originalEnd - } - } - - // Move search start forward - searchStart = range.upperBound - } - } - - // Extract the part after the last preposition - if let end = lastMatchEnd { - let afterPreposition = String(workingLabel[end...]) - if !afterPreposition.isEmpty { - return afterPreposition.lowercasedFirst - } - } - - // No preposition found, use the working label - return workingLabel.lowercasedFirst - } -} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsEquivalenceSnapshotTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsEquivalenceSnapshotTests.swift new file mode 100644 index 00000000..3c38752e --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsEquivalenceSnapshotTests.swift @@ -0,0 +1,141 @@ +import Testing +import Foundation +import RuntimeViewerCore + +/// Golden-file equivalence guard for Evolution 0007, which moves the Objective-C +/// relationship reverse tables out of `ObjCIndexing` and rebuilds them here from +/// the library's event stream. +/// +/// The snapshot is taken through `RuntimeEngine.relationships(for:)` — the public +/// API whose output is what users actually see, and the only vantage point that +/// exists both before and after the migration. (The internal +/// `ObjCInterfaceIndexer.subclasses(of:)` disappears with the library change, so +/// it cannot serve as a shared baseline.) +/// +/// Capture the baseline **before** bumping the MachOObjCSection pin: once the +/// old implementation is gone there is nothing left to compare against. Missing +/// snapshot files are written on the spot and reported as a failure, so a +/// forgotten baseline is loud rather than silent. +@Suite("Relationships equivalence snapshot", .serialized) +struct RelationshipsEquivalenceSnapshotTests { + private enum Anchors { + static let foundationPath = "/System/Library/Frameworks/Foundation.framework/Foundation" + static let libobjcPath = "/usr/lib/libobjc.A.dylib" + } + + /// Anchors chosen for what each one proves about equivalence: + /// + /// - `NSObject` — the largest subclass set available, spanning two images, + /// so a dropped image or a broken cross-image union shows up immediately. + /// - `NSCoding` / `NSCopying` — Objective-C protocols adopted both inline and + /// through categories. The library wrote both kinds into one table; if the + /// rebuilt index splits them apart and queries only one, these shrink. + private static let subclassAnchors = ["NSObject"] + private static let protocolAnchors = ["NSCoding", "NSCopying"] + + @Test("Relationships output matches the pre-migration baseline") + func matchesBaseline() async throws { + let engine = RuntimeEngine(source: .local, engineID: "test-rel-equivalence-snapshot") + try await engine.connect() + try await engine.loadImage(at: Anchors.libobjcPath) + try await engine.loadImage(at: Anchors.foundationPath) + + var report = "" + + for className in Self.subclassAnchors { + let anchor = try #require( + await findObject(named: className, kind: .objc(.type(.class)), in: engine), + "Anchor class \(className) not found in the loaded images." + ) + let relationships = try await engine.relationships(for: anchor) + report += render(section: "subclasses of \(className)", objects: relationships.subclasses) + } + + for protocolName in Self.protocolAnchors { + let anchor = try #require( + await findObject(named: protocolName, kind: .objc(.type(.protocol)), in: engine), + "Anchor protocol \(protocolName) not found in the loaded images." + ) + let relationships = try await engine.relationships(for: anchor) + report += render(section: "conformers of \(protocolName)", objects: relationships.conformingTypes) + } + + try compare(report, againstSnapshotNamed: "relationships-baseline.txt") + } + + // MARK: - Rendering + + /// One line per entry: `kind|displayName|imagePath`. + /// + /// `imagePath` is included deliberately — a category-contributed conformer + /// carries the image declaring the *category*, not the one declaring the + /// class, and that asymmetry is existing behaviour the migration must + /// preserve verbatim. + private func render(section: String, objects: [RuntimeObject]) -> String { + var rendered = "## \(section) (\(objects.count))\n" + for object in objects { + rendered += "\(object.kind)|\(object.displayName)|\(object.imagePath)\n" + } + return rendered + "\n" + } + + private func findObject( + named name: String, + kind: RuntimeObjectKind, + in engine: RuntimeEngine + ) async -> RuntimeObject? { + for imagePath in await engine.loadedImagePaths { + guard let objects = try? await engine.objects(in: imagePath) else { continue } + if let match = objects.first(where: { $0.name == name && $0.kind == kind }) { + return match + } + } + return nil + } + + // MARK: - Snapshot IO + + /// Snapshots live next to this source file so they are reviewable in diffs + /// and travel with the branch; `#filePath` avoids depending on test-bundle + /// resource plumbing. + private func snapshotDirectory() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Snapshots", isDirectory: true) + } + + private func compare(_ report: String, againstSnapshotNamed fileName: String) throws { + let snapshotURL = snapshotDirectory().appendingPathComponent(fileName) + + guard let recorded = try? String(contentsOf: snapshotURL, encoding: .utf8) else { + try FileManager.default.createDirectory( + at: snapshotDirectory(), + withIntermediateDirectories: true + ) + try report.write(to: snapshotURL, atomically: true, encoding: .utf8) + Issue.record( + """ + No baseline snapshot existed; one was just written to \(snapshotURL.path). + Review it, commit it, and re-run. This failure is expected exactly once — \ + on the run that captures the baseline. + """ + ) + return + } + + guard recorded != report else { return } + + let recordedLines = recorded.components(separatedBy: "\n") + let reportLines = report.components(separatedBy: "\n") + let missing = Set(recordedLines).subtracting(reportLines).sorted() + let unexpected = Set(reportLines).subtracting(recordedLines).sorted() + + Issue.record( + """ + Relationships output diverged from the baseline. + Missing \(missing.count) line(s): \(missing.prefix(20).joined(separator: ", ")) + Unexpected \(unexpected.count) line(s): \(unexpected.prefix(20).joined(separator: ", ")) + """ + ) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsWithoutProgressStreamTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsWithoutProgressStreamTests.swift new file mode 100644 index 00000000..5560f10f --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RelationshipsWithoutProgressStreamTests.swift @@ -0,0 +1,78 @@ +import Testing +import Foundation +import RuntimeViewerCore + +/// Regression guard for the one hazard Evolution 0007 could not delegate to the +/// compiler. +/// +/// Since MachOObjCSection 0003 the library keeps no relationship tables of its +/// own — inheritance and protocol adoption leave it *only* through the event +/// handler. An `ObjCInterfaceIndexer` built without a handler silently keeps +/// nothing, and nothing about that is a compile error. +/// +/// `RuntimeObjCSection` used to install its handler only when a progress stream +/// was supplied, which was harmless while the library owned the tables. Six of +/// the seven section-creation call sites pass no progress stream — including +/// `_loadImage(at:)` and background indexing, i.e. how most images are indexed — +/// so leaving that condition in place would empty the Relationships pane for +/// nearly every image, with no error anywhere. +/// +/// This test therefore drives the *no-progress-stream* route deliberately: +/// `loadImage(at:)` creates its sections without a continuation. +@Suite("Relationships without a progress stream") +struct RelationshipsWithoutProgressStreamTests { + private enum Anchors { + static let foundationPath = "/System/Library/Frameworks/Foundation.framework/Foundation" + static let libobjcPath = "/usr/lib/libobjc.A.dylib" + } + + @Test("Images loaded without a progress stream still resolve relationships") + func relationshipsSurviveWithoutProgressStream() async throws { + let engine = RuntimeEngine(source: .local, engineID: "test-rel-no-progress") + try await engine.connect() + + // `loadImage(at:)` builds its sections with no progress continuation — + // the route that loses relationship data if the handler is conditional. + try await engine.loadImage(at: Anchors.libobjcPath) + try await engine.loadImage(at: Anchors.foundationPath) + + var nsObject: RuntimeObject? + for imagePath in await engine.loadedImagePaths { + let objects = try await engine.objects(in: imagePath) + if let match = objects.first(where: { $0.name == "NSObject" && $0.kind == .objc(.type(.class)) }) { + nsObject = match + break + } + } + let anchor = try #require(nsObject, "NSObject not found in the loaded images.") + + let relationships = try await engine.relationships(for: anchor) + + #expect( + !relationships.subclasses.isEmpty, + """ + NSObject resolved zero subclasses. The relationship index is only fed \ + by the indexer's event handler, so this is what an unconditionally \ + required handler being installed conditionally looks like — no error, \ + just an empty pane. + """ + ) + #expect(relationships.subclasses.contains { $0.displayName == "NSString" }) + } + + @Test("ObjC protocol conformers survive without a progress stream") + func conformersSurviveWithoutProgressStream() async throws { + let engine = RuntimeEngine(source: .local, engineID: "test-rel-no-progress-conformers") + try await engine.connect() + try await engine.loadImage(at: Anchors.foundationPath) + + let objects = try await engine.objects(in: Anchors.foundationPath) + let anchor = try #require( + objects.first(where: { $0.name == "NSCopying" && $0.kind == .objc(.type(.protocol)) }), + "NSCopying not found in Foundation." + ) + + let relationships = try await engine.relationships(for: anchor) + #expect(!relationships.conformingTypes.isEmpty) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjCRelationshipIndexTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjCRelationshipIndexTests.swift new file mode 100644 index 00000000..413ea8ad --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/RuntimeObjCRelationshipIndexTests.swift @@ -0,0 +1,181 @@ +import Testing +import Foundation +import ObjCIndexing +@testable import RuntimeViewerCore + +/// Unit tests for the reverse tables Evolution 0007 brought back from +/// MachOObjCSection. +/// +/// The end-to-end equivalence is covered by `RelationshipsEquivalenceSnapshotTests`, +/// which compares real output against a baseline captured before the migration. +/// What that snapshot cannot show is *why* the output matches — these tests pin +/// the three properties it depends on, using synthetic events so each one fails +/// in isolation when broken. +@Suite("RuntimeObjCRelationshipIndex") +struct RuntimeObjCRelationshipIndexTests { + private static let imagePath = "/fixture/Image.framework/Image" + private static let categoryImagePath = "/fixture/Other.framework/Other" + + // MARK: - Property 1: both conformance kinds share one table + + @Test("Inline and category adoptions answer the same query") + func inlineAndCategoryAdoptionsShareOneTable() { + let index = RuntimeObjCRelationshipIndex() + index.record(.conformanceIndexed( + className: "InlineAdopter", + protocolName: "FixtureProtocol", + imagePath: Self.imagePath, + isSwiftStable: false + )) + index.record(.categoryConformanceIndexed( + targetClassName: "CategoryAdopter", + protocolName: "FixtureProtocol", + imagePath: Self.categoryImagePath, + targetIsSwiftStable: false + )) + + let conformers = index.conformingClasses(toProtocol: "FixtureProtocol") + // The library wrote both kinds into one dictionary and answered both + // from a single query. Splitting them into per-case tables would drop + // half the results here. + #expect(conformers.map(\.className) == ["InlineAdopter", "CategoryAdopter"]) + } + + // MARK: - Property 2: inline adoptions precede category ones + + @Test("Replay preserves arrival order across both phases") + func replayPreservesArrivalOrder() { + let index = RuntimeObjCRelationshipIndex() + // The library walks every class before any category, so a consumer + // replaying a single queue in arrival order reproduces that grouping. + for className in ["ClassA", "ClassB", "ClassC"] { + index.record(.conformanceIndexed( + className: className, + protocolName: "FixtureProtocol", + imagePath: Self.imagePath, + isSwiftStable: false + )) + } + for targetClassName in ["CategoryTargetA", "CategoryTargetB"] { + index.record(.categoryConformanceIndexed( + targetClassName: targetClassName, + protocolName: "FixtureProtocol", + imagePath: Self.categoryImagePath, + targetIsSwiftStable: false + )) + } + + #expect( + index.conformingClasses(toProtocol: "FixtureProtocol").map(\.className) + == ["ClassA", "ClassB", "ClassC", "CategoryTargetA", "CategoryTargetB"] + ) + } + + // MARK: - Property 3: dedup keys on all three fields + + @Test("Same class differing only in isSwiftStable is kept twice") + func dedupKeysOnEveryField() { + let index = RuntimeObjCRelationshipIndex() + // A class can reach one protocol twice: inline adoption reads its own + // `class_t` flag, while a category resolves the target across images and + // falls back to `false` when that fails. The library's `OrderedSet` kept + // both entries; collapsing them by class name would look like a fix and + // would be a behaviour change. + index.record(.conformanceIndexed( + className: "BridgedClass", + protocolName: "FixtureProtocol", + imagePath: Self.imagePath, + isSwiftStable: true + )) + index.record(.categoryConformanceIndexed( + targetClassName: "BridgedClass", + protocolName: "FixtureProtocol", + imagePath: Self.imagePath, + targetIsSwiftStable: false + )) + + let conformers = index.conformingClasses(toProtocol: "FixtureProtocol") + #expect(conformers.count == 2) + #expect(conformers.map(\.isSwiftStable) == [true, false]) + } + + @Test("Fully identical records collapse to one") + func identicalRecordsCollapse() { + let index = RuntimeObjCRelationshipIndex() + for _ in 0 ..< 3 { + index.record(.subclassIndexed( + className: "Subclass", + superclass: "Superclass", + imagePath: Self.imagePath, + isSwiftStable: false + )) + } + #expect(index.subclasses(of: "Superclass").count == 1) + } + + // MARK: - Category imagePath asymmetry + + @Test("A category records its own image, not the target class' image") + func categoryRecordsItsOwnImage() { + let index = RuntimeObjCRelationshipIndex() + index.record(.categoryConformanceIndexed( + targetClassName: "NSString", + protocolName: "FixtureProtocol", + imagePath: Self.categoryImagePath, + targetIsSwiftStable: false + )) + + let conformer = try? #require(index.conformingClasses(toProtocol: "FixtureProtocol").first) + // `className` and `imagePath` deliberately do not belong to the same + // image here. Anything keying off `imagePath` to locate the class will + // not find it; that is existing behaviour, preserved verbatim by 0007. + #expect(conformer?.className == "NSString") + #expect(conformer?.imagePath == Self.categoryImagePath) + } + + // MARK: - Lazy build + + @Test("Events recorded after the tables were built still register") + func lateEventsStillRegister() { + let index = RuntimeObjCRelationshipIndex() + index.record(.subclassIndexed( + className: "First", + superclass: "Superclass", + imagePath: Self.imagePath, + isSwiftStable: false + )) + // Force materialization, then keep recording. The build releases the + // pending queue, so a late event has to be folded straight into the + // tables — rebuilding from an emptied queue would lose "First". + #expect(index.subclasses(of: "Superclass").map(\.className) == ["First"]) + + index.record(.subclassIndexed( + className: "Second", + superclass: "Superclass", + imagePath: Self.imagePath, + isSwiftStable: false + )) + #expect(index.subclasses(of: "Superclass").map(\.className) == ["First", "Second"]) + } + + @Test("prewarm does not change what queries return") + func prewarmIsObservationallyNeutral() { + let index = RuntimeObjCRelationshipIndex() + index.record(.subclassIndexed( + className: "Subclass", + superclass: "Superclass", + imagePath: Self.imagePath, + isSwiftStable: false + )) + index.prewarm() + #expect(index.subclasses(of: "Superclass").map(\.className) == ["Subclass"]) + } + + @Test("Progress events contribute nothing") + func progressEventsAreIgnored() { + let index = RuntimeObjCRelationshipIndex() + index.record(.progress(phase: .loadingClasses, itemDescription: "Whatever", currentCount: 1, totalCount: 2)) + #expect(index.subclasses(of: "Superclass").isEmpty) + #expect(index.conformingClasses(toProtocol: "FixtureProtocol").isEmpty) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/Snapshots/relationships-baseline.txt b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/Snapshots/relationships-baseline.txt new file mode 100644 index 00000000..9ee191b3 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/Snapshots/relationships-baseline.txt @@ -0,0 +1,390 @@ +## subclasses of NSObject (307) +Objective-C Class|__IncompleteProtocol|/usr/lib/libobjc.A.dylib +Objective-C Class|__NSBundleTables|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|__NSObserver|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|__NSOperationInternalObserver|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|__NSSKGraphE|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|__NSUnrecognizedTaggedPointer|/usr/lib/libobjc.A.dylib +Objective-C Class|_NSActivityAssertion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAECoercerData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAETranslatorData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributeDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributedStringFromMarkdownCreatorConcrete|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributedStringGrammar|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributedStringGrammarInflection|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributedStringReplacement|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAuxiliaryUndoManagerReference|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSBundleODRDataCommon|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSBundleODRTag|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSBundleOnDemandResourceClientExportedObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSCloudSharingDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSDataCompressor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSDiskOperation|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSDOConversationInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSExtensionContextVendor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSFCFakelinkGroupInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSFileAccessAsynchronousProcessAssertion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSFileAccessAsynchronousProcessAssertionScheduler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSFileAccessClaimPresenterRelinquishment|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSFileWatcherFileHandleInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSIPCallbackSerialization|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSItemProviderTypeCoercion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSJSONReader|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSJSONRoundTrippingNumber|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSJSONWriter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyedCoderOldStyleArray|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyedUnarchiverHelper|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueDebugging|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueDebuggingDeallocSentinel|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueDidWillStats|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueObjectAndKeyPair|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueObjectBox|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueReturnedValueConsistencyStats|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKVOCompatibility|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKVODeallocSentinel|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSLexiconMorphunDictionary|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSLocalizedStringResource|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSMetadataItemPrivateIvars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSMetadataQueryPrivateIvars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSMetadataQuerySortingPseudoItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSObserverList|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSOrderedCollectionDifferenceMoves|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPerformanceMeter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPersonNameComponentsData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPersonNameComponentsFormatterData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPersonNameComponentsStyleFormatter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPredicateOperatorUtilities|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPredicateUtilities|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSProgressSubscriber|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSSharedKeySetS|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSStringFormattingOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSThreadData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSThreadPerformInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSTimerBlockTarget|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoActionInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoManagerAuxiliaryExportedObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoManagerMainExportedObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoStack|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXMLDocumentExtraIvars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXMLPlaceholderNode|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCAllowListHolder|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCBoost|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionClassCache|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionExpectedReplies|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionExpectedReplyInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionExportedObjectTable|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionImportInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCConnectionRequestedReplies|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCDistantObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSXPCRemoteTransport|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAEDescriptorTranslator|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAffineTransform|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAKDeserializer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAKDeserializerStream|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAKSerializer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAKSerializerStream|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleEventDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleEventHandling|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleEventManager|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleScript|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSArrayChange|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAssertionHandler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedString|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedStringMarkdownParsingOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedStringMarkdownSourcePosition|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAutoreleasePool|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSBackgroundActivityScheduler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSBoundKeyPath|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSBundle|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSBundleResourceRequest|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCFStreamWeakDelegateWrapper|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCFType|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCharacterSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSClassDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCoder|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCondition|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSConditionLock|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSConnection|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSConnectionHelper|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCreateCommandMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDateInterval|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDecimalNumberHandler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDeserializer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDictionaryEntry|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDistantObjectRequest|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDistantObjectTableEntry|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDistributedLock|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDocInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSEncodingDetectionBuffer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSEncodingDetectionPlaceholder|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSEncodingDetector|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSError|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExpression|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtension|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionContext|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionService_Subsystem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionURLResult|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessArbiter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessArbiterProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessClaim|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessIntent|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessNode|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessProcessManager|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessProcessMonitor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileAccessSubarbiter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileCoordinator|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileCoordinatorAccessorBlockCompletion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileCoordinatorReacquisitionBlockCompletion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileHandle|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileManager|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFilePresenterManagedProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFilePresenterOperationRecord|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFilePresenterRelinquishment|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFilePresenterXPCMessenger|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFilePromiseWriteToken|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderKernelFileMaterializationInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderKernelMaterializationInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderKernelPartialFolderMaterializationInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderMessageInterface|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderMessenger|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderMovingInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderMovingResponse|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderPresenterInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderService|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProviderXPCMessenger|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProvidingInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileProvidingResponse|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileReactorProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileVersion|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileWatcher|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileWatcherObservations|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFileWrapper|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFormatter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSGarbageCollector|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSHashTable|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSIndexPath|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSIndexSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSInflectionRule|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSItemProvider|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSItemProviderRepresentation|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSItemRepresentationLoadResult|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSJSONSerialization|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyBinding|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueAccessor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueContainerClass|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueMutatingCollectionMethodSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueNonmutatingCollectionMethodSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueObservance|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueObservationInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueProperty|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueProxyShareKey|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueShareableObservationInfoKey|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueSharedObservers|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueSharedObserversSnapshot|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSLinguisticTagger|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSLocalizedNumberFormatRule|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSLock|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSLookupMatch|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMapTable|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMeasurement|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMetadataItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMetadataQuery|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMetadataQueryAttributeValueTuple|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMetadataQueryResultGroup|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphology|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphologyCustomPronoun|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphologyPronoun|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMultiReadUniWriteLock|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSNotification|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSNotificationCenter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSNotificationQueue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSObservation|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSObservationSink|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSObservationSource|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSObservedValue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOperation|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOperationQueue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrderedCollectionChange|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrderedCollectionDifference|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrderedSetChange|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrthography|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPersonNameComponents|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPipe|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPointerArray|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPointerFunctions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPortMessage|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPortNameServer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPositionalSpecifier|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPositionalSpecifierMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPredicate|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPredicateOperator|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPredicateValidator|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPresentationIntent|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProcessInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProgress|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProgressPublisherProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProgressRegistrar|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProgressSubscriberProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSProgressValues|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPropertyListSerialization|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSRecursiveLock|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSRegularExpression|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSRLEArray|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScanner|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptArgumentDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptClassDescriptionMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCoercionHandler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommand|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommandConstructionContext|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommandDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommandDescriptionMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommandMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptEnumeratorDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptExecutionContext|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptExecutionContextMoreIVars|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptingAppleEventHandler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptObjectSpecifier|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptPropertyDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptRecordFieldDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptSDEFElement|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptSDEFParser|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptSuiteDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptSuiteRegistry|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptSynonymDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptTypeDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptWhoseTest|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSecurityScopedURLWrapper|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSerializer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSetChange|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSmartPunctuationController|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSmartQuoteOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSortDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSpellServer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSString|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTask|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTermOfAddress|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTextCheckingKeyEvent|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTextCheckingResult|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSThread|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUbiquitousKeyValueStore|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUndoManager|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUnit|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUnitConverter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLComponents|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLConnectionDelegateProxy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLFileTypeMappings|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLHandle|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLHostNameAddressInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLKeyValuePair|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLPromisePair|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLQueryItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLQueue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLQueueNode|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserActivity|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotification|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotificationAction|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotificationCenter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserScriptTask|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserScriptTaskRunner|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserScriptTaskServiceDelegate|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUUID|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSValue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSValueTransformer|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLContext|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLNode|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLParser|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLSAXParser|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLSchemaType|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLTidy|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLTreeReader|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXPCConnection|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXPCInterface|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXPCListener|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXPCListenerEndpoint|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSZipFileArchive|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|Protocol|/usr/lib/libobjc.A.dylib +Objective-C Class|UIKit_PKSubsystem|/System/Library/Frameworks/Foundation.framework/Foundation + +## conformers of NSCoding (9) +Objective-C Class|NSDecimalNumberHandler|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDistantObject|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFormatter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSNotification|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommand|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptCommandDescription|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptObjectSpecifier|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScriptWhoseTest|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotification|/System/Library/Frameworks/Foundation.framework/Foundation + +## conformers of NSCopying (68) +Objective-C Class|_NSAttributedStringGrammar|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSAttributedStringGrammarInflection|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueObjectAndKeyPair|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSKeyValueObjectBox|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSLocalizedStringResource|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPersonNameComponentsData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSPersonNameComponentsFormatterData|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSSharedKeySetS|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSStringFormattingOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUndoActionInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|_NSUUIDBridge|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAffineTransform|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleEventDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAppleScript|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSArrayChanges|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedString|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedStringMarkdownParsingOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSAttributedStringMarkdownSourcePosition|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSCharacterSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDateInterval|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSDocInfo|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSError|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExpression|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionContext|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSExtensionItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSFormatter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSHashTable|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSIndexPath|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSIndexSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSInflectionRule|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSItemProvider|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSItemRepresentationLoadResult|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSKeyValueProperty|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSLocalizedNumberFormatRule|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMapTable|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMeasurement|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphology|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphologyCustomPronoun|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMorphologyPronoun|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSMutableCharacterSet|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSNotification|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrderedSetChanges|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSOrthography|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPersonNameComponents|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPersonNameComponentsFormatter|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPointerArray|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPointerFunctions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPredicate|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPredicateOperator|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSPresentationIntent|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSRegularExpression|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSRLEArray|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSScanner|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSetChanges|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSmartQuoteOptions|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSSortDescriptor|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSString|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTermOfAddress|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTextCheckingKeyEvent|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSTextCheckingResult|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUnit|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLComponents|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSURLQueryItem|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotification|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUserNotificationAction|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSUUID|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSValue|/System/Library/Frameworks/Foundation.framework/Foundation +Objective-C Class|NSXMLNode|/System/Library/Frameworks/Foundation.framework/Foundation + diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerAdditionalTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerAdditionalTests.swift deleted file mode 100644 index 54955410..00000000 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerAdditionalTests.swift +++ /dev/null @@ -1,410 +0,0 @@ -import Testing -import Foundation -import RuntimeViewerCore - -// MARK: - SwiftVTableOffset Tests - -@Suite("Transformer.SwiftVTableOffset") -struct TransformerSwiftVTableOffsetTests { - // MARK: - Basic Properties - - @Test("Display name") - func displayName() { - #expect(Transformer.SwiftVTableOffset.displayName == "Swift VTable Offset Comment") - } - - @Test("Default initialization") - func defaultInit() { - let module = Transformer.SwiftVTableOffset() - #expect(module.isEnabled == false) - #expect(module.template == Transformer.SwiftVTableOffset.Templates.standard) - #expect(module.labeledTemplate == Transformer.SwiftVTableOffset.Templates.standardLabeled) - #expect(module.useHexadecimal == false) - } - - @Test("Custom initialization") - func customInit() { - let module = Transformer.SwiftVTableOffset( - isEnabled: true, - template: "custom ${slotOffset}", - labeledTemplate: "custom labeled ${slotOffset} ${label}", - useHexadecimal: true - ) - #expect(module.isEnabled == true) - #expect(module.template == "custom ${slotOffset}") - #expect(module.labeledTemplate == "custom labeled ${slotOffset} ${label}") - #expect(module.useHexadecimal == true) - } - - // MARK: - Transform - - @Test("Transform with standard template (no label)") - func transformStandard() { - let module = Transformer.SwiftVTableOffset() - let result = module.transform(.init(slotOffset: 42, label: nil)) - #expect(result == "VTable Offset: 42") - } - - @Test("Transform with standard labeled template") - func transformStandardLabeled() { - let module = Transformer.SwiftVTableOffset() - let result = module.transform(.init(slotOffset: 42, label: "getter")) - #expect(result == "VTable Offset (getter): 42") - } - - @Test("Transform with compact template (no label)") - func transformCompact() { - let module = Transformer.SwiftVTableOffset(template: Transformer.SwiftVTableOffset.Templates.compact) - let result = module.transform(.init(slotOffset: 10, label: nil)) - #expect(result == "VTable[10]") - } - - @Test("Transform with compact labeled template") - func transformCompactLabeled() { - let module = Transformer.SwiftVTableOffset( - labeledTemplate: Transformer.SwiftVTableOffset.Templates.compactLabeled - ) - let result = module.transform(.init(slotOffset: 10, label: "setter")) - #expect(result == "VTable[10] (setter)") - } - - @Test("Transform with offset-only template") - func transformOffsetOnly() { - let module = Transformer.SwiftVTableOffset(template: Transformer.SwiftVTableOffset.Templates.offsetOnly) - let result = module.transform(.init(slotOffset: 255, label: nil)) - #expect(result == "255") - } - - @Test("Transform with hexadecimal formatting") - func transformHexadecimal() { - let module = Transformer.SwiftVTableOffset(useHexadecimal: true) - let result = module.transform(.init(slotOffset: 255, label: nil)) - #expect(result == "VTable Offset: 0xFF") - } - - @Test("Transform with hexadecimal and label") - func transformHexadecimalLabeled() { - let module = Transformer.SwiftVTableOffset(useHexadecimal: true) - let result = module.transform(.init(slotOffset: 16, label: "getter")) - #expect(result == "VTable Offset (getter): 0x10") - } - - @Test("Transform with zero offset") - func transformZeroOffset() { - let module = Transformer.SwiftVTableOffset() - let result = module.transform(.init(slotOffset: 0, label: nil)) - #expect(result == "VTable Offset: 0") - } - - @Test("Transform uses template when label is nil, labeledTemplate when label is provided") - func transformTemplateSelection() { - let module = Transformer.SwiftVTableOffset( - template: "UNLABELED: ${slotOffset}", - labeledTemplate: "LABELED: ${slotOffset} ${label}" - ) - let unlabeledResult = module.transform(.init(slotOffset: 5, label: nil)) - #expect(unlabeledResult == "UNLABELED: 5") - - let labeledResult = module.transform(.init(slotOffset: 5, label: "test")) - #expect(labeledResult == "LABELED: 5 test") - } - - @Test("Transform with empty label still uses labeledTemplate") - func transformEmptyLabel() { - let module = Transformer.SwiftVTableOffset() - let result = module.transform(.init(slotOffset: 42, label: "")) - // label is non-nil (empty string), so labeledTemplate is used - #expect(result == "VTable Offset (): 42") - } - - // MARK: - Contains - - @Test("Contains token checks both templates") - func containsToken() { - let module = Transformer.SwiftVTableOffset() - #expect(module.contains(.slotOffset) == true) - #expect(module.contains(.label) == true) // label is in labeledTemplate - } - - @Test("Contains returns false for absent token") - func containsAbsentToken() { - let module = Transformer.SwiftVTableOffset( - template: "no tokens here", - labeledTemplate: "still no tokens" - ) - #expect(module.contains(.slotOffset) == false) - #expect(module.contains(.label) == false) - } - - @Test("Contains returns true when token is only in one template") - func containsInOneTemplate() { - let module = Transformer.SwiftVTableOffset( - template: "${slotOffset}", - labeledTemplate: "no offset token" - ) - #expect(module.contains(.slotOffset) == true) - } - - // MARK: - Token - - @Test("Token placeholders", arguments: [ - (Transformer.SwiftVTableOffset.Token.slotOffset, "${slotOffset}"), - (.label, "${label}"), - ] as [(Transformer.SwiftVTableOffset.Token, String)]) - func tokenPlaceholder(token: Transformer.SwiftVTableOffset.Token, expected: String) { - #expect(token.placeholder == expected) - } - - @Test("Token display names", arguments: [ - (Transformer.SwiftVTableOffset.Token.slotOffset, "Slot Offset"), - (.label, "Label"), - ] as [(Transformer.SwiftVTableOffset.Token, String)]) - func tokenDisplayName(token: Transformer.SwiftVTableOffset.Token, expected: String) { - #expect(token.displayName == expected) - } - - @Test("Token allCases count") - func tokenAllCases() { - #expect(Transformer.SwiftVTableOffset.Token.allCases.count == 2) - } - - // MARK: - Templates - - @Test("Templates.all count") - func templatesAllCount() { - #expect(Transformer.SwiftVTableOffset.Templates.all.count == 3) - } - - @Test("Templates.allLabeled count") - func templatesAllLabeledCount() { - #expect(Transformer.SwiftVTableOffset.Templates.allLabeled.count == 3) - } - - @Test("All templates contain slotOffset token") - func templatesContainOffset() { - for (_, template) in Transformer.SwiftVTableOffset.Templates.all { - #expect(template.contains("${slotOffset}")) - } - } - - @Test("All labeled templates contain slotOffset token") - func labeledTemplatesContainOffset() { - for (_, template) in Transformer.SwiftVTableOffset.Templates.allLabeled { - #expect(template.contains("${slotOffset}")) - } - } - - @Test("Standard templates match expected values") - func standardTemplateValues() { - #expect(Transformer.SwiftVTableOffset.Templates.standard == "VTable Offset: ${slotOffset}") - #expect(Transformer.SwiftVTableOffset.Templates.standardLabeled == "VTable Offset (${label}): ${slotOffset}") - #expect(Transformer.SwiftVTableOffset.Templates.compact == "VTable[${slotOffset}]") - #expect(Transformer.SwiftVTableOffset.Templates.compactLabeled == "VTable[${slotOffset}] (${label})") - #expect(Transformer.SwiftVTableOffset.Templates.offsetOnly == "${slotOffset}") - } - - // MARK: - Codable - - @Test("Codable round-trip") - func codable() throws { - let original = Transformer.SwiftVTableOffset( - isEnabled: true, - template: "custom ${slotOffset}", - labeledTemplate: "labeled ${slotOffset} ${label}", - useHexadecimal: true - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(Transformer.SwiftVTableOffset.self, from: data) - #expect(decoded == original) - } - - @Test("Codable decoding with missing fields uses defaults") - func codableDefaults() throws { - let json = "{}".data(using: .utf8)! - let decoded = try JSONDecoder().decode(Transformer.SwiftVTableOffset.self, from: json) - #expect(decoded.isEnabled == false) - #expect(decoded.template == Transformer.SwiftVTableOffset.Templates.standard) - #expect(decoded.labeledTemplate == Transformer.SwiftVTableOffset.Templates.standardLabeled) - #expect(decoded.useHexadecimal == false) - } - - // MARK: - Equatable / Hashable - - @Test("Equatable") - func equatable() { - let moduleA = Transformer.SwiftVTableOffset(isEnabled: true, useHexadecimal: true) - let moduleB = Transformer.SwiftVTableOffset(isEnabled: true, useHexadecimal: true) - let moduleC = Transformer.SwiftVTableOffset(isEnabled: false, useHexadecimal: true) - #expect(moduleA == moduleB) - #expect(moduleA != moduleC) - } -} - -// MARK: - SwiftMemberAddress Tests - -@Suite("Transformer.SwiftMemberAddress") -struct TransformerSwiftMemberAddressTests { - // MARK: - Basic Properties - - @Test("Display name") - func displayName() { - #expect(Transformer.SwiftMemberAddress.displayName == "Swift Member Address Comment") - } - - @Test("Default initialization") - func defaultInit() { - let module = Transformer.SwiftMemberAddress() - #expect(module.isEnabled == false) - #expect(module.template == Transformer.SwiftMemberAddress.Templates.standard) - #expect(module.useHexadecimal == true) - } - - @Test("Custom initialization") - func customInit() { - let module = Transformer.SwiftMemberAddress( - isEnabled: true, - template: "custom ${offset}", - useHexadecimal: false - ) - #expect(module.isEnabled == true) - #expect(module.template == "custom ${offset}") - #expect(module.useHexadecimal == false) - } - - // MARK: - Transform - - @Test("Transform with standard template (hex)") - func transformStandardHex() { - let module = Transformer.SwiftMemberAddress() - let result = module.transform(.init(offset: 0x1234)) - #expect(result == "Address: 0x1234") - } - - @Test("Transform with standard template (decimal)") - func transformStandardDecimal() { - let module = Transformer.SwiftMemberAddress(useHexadecimal: false) - let result = module.transform(.init(offset: 4660)) - #expect(result == "Address: 4660") - } - - @Test("Transform with compact template") - func transformCompact() { - let module = Transformer.SwiftMemberAddress(template: Transformer.SwiftMemberAddress.Templates.compact) - let result = module.transform(.init(offset: 0xFF)) - #expect(result == "0xFF") - } - - @Test("Transform with labeled template") - func transformLabeled() { - let module = Transformer.SwiftMemberAddress(template: Transformer.SwiftMemberAddress.Templates.labeled) - let result = module.transform(.init(offset: 0xAB)) - #expect(result == "addr: 0xAB") - } - - @Test("Transform with zero offset") - func transformZeroOffset() { - let module = Transformer.SwiftMemberAddress() - let result = module.transform(.init(offset: 0)) - #expect(result == "Address: 0x0") - } - - @Test("Transform with large offset (hex)") - func transformLargeOffset() { - let module = Transformer.SwiftMemberAddress() - let result = module.transform(.init(offset: 0xDEADBEEF)) - #expect(result == "Address: 0xDEADBEEF") - } - - @Test("Transform with custom template") - func transformCustomTemplate() { - let module = Transformer.SwiftMemberAddress(template: "offset=${offset}") - let result = module.transform(.init(offset: 256)) - #expect(result == "offset=0x100") - } - - // MARK: - Contains - - @Test("Contains offset token in standard template") - func containsOffsetToken() { - let module = Transformer.SwiftMemberAddress() - #expect(module.contains(.offset) == true) - } - - @Test("Contains returns false for absent token") - func containsAbsentToken() { - let module = Transformer.SwiftMemberAddress(template: "no tokens here") - #expect(module.contains(.offset) == false) - } - - // MARK: - Token - - @Test("Token placeholder") - func tokenPlaceholder() { - #expect(Transformer.SwiftMemberAddress.Token.offset.placeholder == "${offset}") - } - - @Test("Token display name") - func tokenDisplayName() { - #expect(Transformer.SwiftMemberAddress.Token.offset.displayName == "Offset") - } - - @Test("Token allCases count") - func tokenAllCases() { - #expect(Transformer.SwiftMemberAddress.Token.allCases.count == 1) - } - - // MARK: - Templates - - @Test("Templates.all count") - func templatesAllCount() { - #expect(Transformer.SwiftMemberAddress.Templates.all.count == 3) - } - - @Test("All templates contain offset token") - func templatesContainOffset() { - for (_, template) in Transformer.SwiftMemberAddress.Templates.all { - #expect(template.contains("${offset}")) - } - } - - @Test("Standard template values") - func standardTemplateValues() { - #expect(Transformer.SwiftMemberAddress.Templates.standard == "Address: ${offset}") - #expect(Transformer.SwiftMemberAddress.Templates.compact == "${offset}") - #expect(Transformer.SwiftMemberAddress.Templates.labeled == "addr: ${offset}") - } - - // MARK: - Codable - - @Test("Codable round-trip") - func codable() throws { - let original = Transformer.SwiftMemberAddress( - isEnabled: true, - template: "custom ${offset}", - useHexadecimal: false - ) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(Transformer.SwiftMemberAddress.self, from: data) - #expect(decoded == original) - } - - @Test("Codable decoding with missing fields uses defaults") - func codableDefaults() throws { - let json = "{}".data(using: .utf8)! - let decoded = try JSONDecoder().decode(Transformer.SwiftMemberAddress.self, from: json) - #expect(decoded.isEnabled == false) - #expect(decoded.template == Transformer.SwiftMemberAddress.Templates.standard) - #expect(decoded.useHexadecimal == true) - } - - // MARK: - Equatable / Hashable - - @Test("Equatable") - func equatable() { - let moduleA = Transformer.SwiftMemberAddress(isEnabled: true, useHexadecimal: false) - let moduleB = Transformer.SwiftMemberAddress(isEnabled: true, useHexadecimal: false) - let moduleC = Transformer.SwiftMemberAddress(isEnabled: false, useHexadecimal: false) - #expect(moduleA == moduleB) - #expect(moduleA != moduleC) - } -} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerConfigurationTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerConfigurationTests.swift new file mode 100644 index 00000000..7db99048 --- /dev/null +++ b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerConfigurationTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +import OutputTransformer +import ObjCOutputTransformer +import SwiftOutputTransformer +import RuntimeViewerCore + +/// The concrete transformer modules live with their subject matter — the ObjC +/// ones in MachOObjCSection, the Swift ones in MachOSwiftSection — and are +/// tested there. What is exercised here is the aggregate that only +/// RuntimeViewer needs: it spans both halves and is what gets persisted. +@Suite("Transformer.Configuration") +struct TransformerConfigurationTests { + @Test("Both halves are reachable through one namespace") + func bothHalvesShareOneNamespace() { + // The modules come from two different packages but extend the same + // `Transformer` namespace, so neither needs qualifying. + #expect(Transformer.CType.displayName == "C Type Replacement") + #expect(Transformer.SwiftEnumLayout.displayName == "Enum Layout Comment") + } + + @Test("Persistence round-trips and tolerates missing keys") + func persistenceRoundTrips() throws { + var configuration = Transformer.Configuration() + configuration.swift.swiftFieldOffset.isEnabled = true + configuration.swift.swiftEnumLayout = .explained + configuration.objc.cType = .init(isEnabled: true, replacements: Transformer.CType.Presets.foundation) + + let encoded = try JSONEncoder().encode(configuration) + let decoded = try JSONDecoder().decode(Transformer.Configuration.self, from: encoded) + #expect(decoded == configuration) + + // Settings stored by older versions may lack any key. + let emptyDecoded = try JSONDecoder().decode(Transformer.Configuration.self, from: Data("{}".utf8)) + #expect(emptyDecoded == .default) + } + + @Test("hasEnabledModules covers both sides") + func hasEnabledModulesCoversBothSides() { + var configuration = Transformer.Configuration.default + #expect(!configuration.hasEnabledModules) + configuration.objc.ivarOffset.isEnabled = true + #expect(configuration.hasEnabledModules) + configuration = .default + configuration.swift.swiftTypeLayout.isEnabled = true + #expect(configuration.hasEnabledModules) + } +} diff --git a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerTests.swift b/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerTests.swift deleted file mode 100644 index e209a688..00000000 --- a/RuntimeViewerCore/Tests/RuntimeViewerCoreTests/TransformerTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import Testing -import Semantic -@testable import RuntimeViewerCore - -/// The Swift-side transformer template engine moved library-side -/// (MachOSwiftSection's `OutputTransformer` module), where its behavior is -/// exhaustively unit tested (`TransformerModuleTests`, -/// `EnumLayoutCommentTemplateTests`). This package still owns the ObjC-side -/// modules (`CType`, `ObjCIvarOffset`) and the aggregate persistence -/// `Configuration`, so these tests cover those plus the re-export seam. -@Suite("Transformer") -struct TransformerTests { - // MARK: - Re-export seam - - @Test("re-exported namespace is visible with the historical spelling") - func reExportedNamespaceIsVisible() { - #expect(!Transformer.Configuration.default.hasEnabledModules) - #expect(Transformer.SwiftEnumLayout.displayName == "Enum Layout Comment") - #expect(!Transformer.SwiftEnumLayout.CaseTemplates.all.isEmpty) - } - - @Test("enabled modules render through the library engine") - func enabledModulesRenderThroughLibraryEngine() { - var fieldOffsetModule = Transformer.SwiftFieldOffset(isEnabled: true) - fieldOffsetModule.template = Transformer.SwiftFieldOffset.Templates.range - #expect(fieldOffsetModule.transform(.init(startOffset: 0, endOffset: 8)) == "0x0 ..< 0x8") - - let compactEnumModule = Transformer.SwiftEnumLayout.compact - let caseInput = Transformer.SwiftEnumLayout.CaseInput( - caseIndex: 1, - caseName: "payload case #1", - declaredName: "value", - isPayloadCase: true, - tagValue: 1, - payloadValue: 0 - ) - #expect(compactEnumModule.transformCase(caseInput) == "[0x01] `value` — payload case, tag 1") - } - - // MARK: - ObjC-side modules (still owned here) - - private func semanticKeywords(_ keywords: [String]) -> SemanticString { - var components: [AtomicComponent] = [] - for (keywordIndex, keyword) in keywords.enumerated() { - if keywordIndex > 0 { - components.append(AtomicComponent(string: " ", type: .standard)) - } - components.append(AtomicComponent(string: keyword, type: .keyword)) - } - return SemanticString(components: components) - } - - @Test("CType replaces the longest pattern first") - func cTypeReplacesLongestPatternFirst() { - var module = Transformer.CType(isEnabled: true) - module.replacements = Transformer.CType.Presets.stdint - // "unsigned long long" must map to uint64_t, not "unsigned" + int64_t. - #expect(module.transform(semanticKeywords(["unsigned", "long", "long"])).string == "uint64_t") - #expect(module.transform(semanticKeywords(["long"])).string == "int64_t") - } - - @Test("CType leaves non-keyword components untouched") - func cTypeLeavesNonKeywordComponentsUntouched() { - var module = Transformer.CType(isEnabled: true) - module.replacements = [.double: "CGFloat"] - let input = SemanticString(components: [ - AtomicComponent(string: "double", type: .keyword), - AtomicComponent(string: " ", type: .standard), - AtomicComponent(string: "value", type: .variable), - ]) - #expect(module.transform(input).string == "CGFloat value") - } - - @Test("ObjCIvarOffset renders its template") - func objcIvarOffsetRendersTemplate() { - let module = Transformer.ObjCIvarOffset(isEnabled: true) - #expect(module.transform(.init(offset: 8)) == "offset: 0x8") - } - - // MARK: - Aggregate persistence - - @Test("configuration persistence round-trips and tolerates missing keys") - func configurationPersistenceRoundTrips() throws { - var configuration = Transformer.Configuration() - configuration.swift.swiftFieldOffset.isEnabled = true - configuration.swift.swiftEnumLayout = .explained - configuration.objc.cType = .init(isEnabled: true, replacements: Transformer.CType.Presets.foundation) - - let encoded = try JSONEncoder().encode(configuration) - let decoded = try JSONDecoder().decode(Transformer.Configuration.self, from: encoded) - #expect(decoded == configuration) - - // Settings stored by older versions may lack any key. - let emptyDecoded = try JSONDecoder().decode(Transformer.Configuration.self, from: Data("{}".utf8)) - #expect(emptyDecoded == .default) - } - - @Test("hasEnabledModules covers both sides") - func hasEnabledModulesCoversBothSides() { - var configuration = Transformer.Configuration.default - #expect(!configuration.hasEnabledModules) - configuration.objc.ivarOffset.isEnabled = true - #expect(configuration.hasEnabledModules) - configuration = .default - configuration.swift.swiftTypeLayout.isEnabled = true - #expect(configuration.hasEnabledModules) - } -}