From 66becba6c768a7d4f2f0b8097356d64bc16892b9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:03:53 -0400 Subject: [PATCH 01/22] Add CodeGraphModel package with shared graph schema Scaffolds Tools/CodeGraph as a standalone SwiftPM package for the code relationship visualizer. CodeGraphModel defines the Codable snapshot schema (CodeGraph/Node/Edge/Module with NodeKind/EdgeKind/Origin), a shared ISO-8601 JSON coding config, and a bundled hand-written sample-graph.json so the viewer can be built before the extractor exists. Node decodes tolerantly (defaults isGeneric) for hand-written and older snapshots. Tests round-trip the fixture and assert referential integrity. Closes plan to-do: scaffold-model. Co-authored-by: Cursor --- Tools/CodeGraph/Package.swift | 27 ++++ .../Sources/CodeGraphModel/CodeGraph.swift | 77 ++++++++++ .../CodeGraphModel/CodeGraphCoding.swift | 19 +++ .../Sources/CodeGraphModel/Edge.swift | 73 ++++++++++ .../Sources/CodeGraphModel/Module.swift | 30 ++++ .../Sources/CodeGraphModel/Node.swift | 133 ++++++++++++++++++ .../Resources/sample-graph.json | 64 +++++++++ .../CodeGraphModelTests.swift | 50 +++++++ 8 files changed, 473 insertions(+) create mode 100644 Tools/CodeGraph/Package.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/Module.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/Node.swift create mode 100644 Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json create mode 100644 Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift diff --git a/Tools/CodeGraph/Package.swift b/Tools/CodeGraph/Package.swift new file mode 100644 index 0000000..5b3cde9 --- /dev/null +++ b/Tools/CodeGraph/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "CodeGraph", + platforms: [ + .macOS(.v13), + .iOS(.v16), + ], + products: [ + .library(name: "CodeGraphModel", targets: ["CodeGraphModel"]), + ], + targets: [ + .target( + name: "CodeGraphModel", + resources: [ + .process("Resources"), + ], + ), + .testTarget( + name: "CodeGraphModelTests", + dependencies: [ + "CodeGraphModel", + ], + ), + ], +) diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift b/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift new file mode 100644 index 0000000..d928e1c --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift @@ -0,0 +1,77 @@ +import Foundation + +/// A complete snapshot of a repository's types and the relationships between +/// them, produced by `code-graph-extract` and rendered by the viewer. +/// +/// Everything discovered is kept; narrowing the graph down to something +/// readable is purely a viewer-side filtering concern. +public struct CodeGraph: Codable, Sendable, Hashable { + /// Bumped when the on-disk shape changes so the viewer can reject snapshots + /// it cannot read. + public var schemaVersion: Int + /// When the snapshot was generated. + public var generatedAt: Date + /// Absolute path of the repository the snapshot was taken from. + public var repoPath: String + /// `git` commit the snapshot reflects, when known. + public var commit: String? + public var modules: [Module] + public var nodes: [Node] + public var edges: [Edge] + + public init( + schemaVersion: Int = CodeGraph.currentSchemaVersion, + generatedAt: Date, + repoPath: String, + commit: String? = nil, + modules: [Module], + nodes: [Node], + edges: [Edge], + ) { + self.schemaVersion = schemaVersion + self.generatedAt = generatedAt + self.repoPath = repoPath + self.commit = commit + self.modules = modules + self.nodes = nodes + self.edges = edges + } + + /// The schema version this build of the model produces and understands. + public static let currentSchemaVersion = 1 +} + +extension CodeGraph { + /// Decode a snapshot from `graph.json` data using the shared coding config. + public static func decoded(from data: Data) throws -> CodeGraph { + try CodeGraphCoding.decoder.decode(CodeGraph.self, from: data) + } + + /// Encode this snapshot to pretty-printed `graph.json` data. + public func encoded() throws -> Data { + try CodeGraphCoding.encoder.encode(self) + } + + /// A small hand-written snapshot bundled for tests, previews, and for + /// developing the viewer before the extractor produces real data. + public static func sample() throws -> CodeGraph { + guard + let url = Bundle.module.url(forResource: "sample-graph", withExtension: "json") + else { + throw CodeGraphError.resourceMissing("sample-graph.json") + } + return try decoded(from: Data(contentsOf: url)) + } +} + +/// Errors surfaced by the model layer. +public enum CodeGraphError: Error, CustomStringConvertible { + case resourceMissing(String) + + public var description: String { + switch self { + case let .resourceMissing(name): + "Bundled resource is missing: \(name)" + } + } +} diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift b/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift new file mode 100644 index 0000000..eb237b1 --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Shared JSON coding configuration so the extractor (writer) and the viewer +/// (reader) always agree on the `graph.json` format: ISO-8601 dates and a +/// stable key order that diffs cleanly in source control. +public enum CodeGraphCoding { + public static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return encoder + } + + public static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift b/Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift new file mode 100644 index 0000000..e469346 --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift @@ -0,0 +1,73 @@ +import Foundation + +/// A directed relationship between two nodes. +public struct Edge: Codable, Sendable, Hashable, Identifiable { + public var id: String + /// The "from" end: the subclass, the conforming type, or the type that owns + /// the member through which the reference flows. + public var source: String + /// The "to" end: the superclass, the protocol, or the referenced type. + public var target: String + public var kind: EdgeKind + /// The member (property / method / initializer) the relationship flowed + /// through, when applicable — e.g. the property whose type is `target`. + public var viaMemberID: String? + /// How many distinct source occurrences were collapsed into this edge. + public var count: Int + + public init( + id: String, + source: String, + target: String, + kind: EdgeKind, + viaMemberID: String? = nil, + count: Int = 1, + ) { + self.id = id + self.source = source + self.target = target + self.kind = kind + self.viaMemberID = viaMemberID + self.count = count + } +} + +/// The semantic of an ``Edge``. The extractor records every kind it can find; +/// the viewer decides which to show. +public enum EdgeKind: String, Codable, Sendable, CaseIterable, Hashable { + /// Subclass to superclass. + case inheritance + /// Conforming type to protocol. + case conformance + /// Overriding member to the member it overrides. + case override + /// Container to a nested type or member it owns. + case member + /// A type owns a stored / computed property of the target type. + case propertyType + /// A function or initializer references the target in its parameters or + /// return type. + case paramOrReturnType + /// A type constructs, or is initialized with, the target. + case construction + /// A generic parameter is constrained to the target. + case genericConstraint + /// A protocol's associated type relates to the target. + case associatedType + /// A module depends on another module. + case moduleDependency + /// A cross-type reference not captured by a more specific kind. + case reference + + /// Whether the relationship is structural (the classic UML "is-a" / nesting + /// edges) as opposed to a usage / data-flow reference. + public var isStructural: Bool { + switch self { + case .inheritance, .conformance, .override, .member, .moduleDependency: + true + case .propertyType, .paramOrReturnType, .construction, .genericConstraint, + .associatedType, .reference: + false + } + } +} diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Module.swift b/Tools/CodeGraph/Sources/CodeGraphModel/Module.swift new file mode 100644 index 0000000..04964ba --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/Module.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A build module (a SwiftPM / Tuist target) and the modules it depends on. +public struct Module: Codable, Sendable, Hashable, Identifiable { + public var id: String { + name + } + + /// Module name, e.g. `WhereCore`. + public var name: String + public var kind: ModuleKind + /// Names of the modules this module depends on. + public var dependencies: [String] + + public init(name: String, kind: ModuleKind, dependencies: [String] = []) { + self.name = name + self.kind = kind + self.dependencies = dependencies + } +} + +/// The role a ``Module`` plays in the build. +public enum ModuleKind: String, Codable, Sendable, CaseIterable, Hashable { + case library + case app + case appExtension + case test + case external + case unknown +} diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Node.swift b/Tools/CodeGraph/Sources/CodeGraphModel/Node.swift new file mode 100644 index 0000000..83210bb --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/Node.swift @@ -0,0 +1,133 @@ +import Foundation + +/// A single vertex in the graph: a type, a member of a type, a free function, a +/// module, or an external (SDK / third-party) symbol referenced by the repo. +public struct Node: Codable, Sendable, Hashable, Identifiable { + /// Stable identifier. For symbols this is the compiler USR; for modules it + /// is the module name prefixed with `module:`. + public var id: String + /// Display name (unqualified), e.g. `WhereSession` or `report`. + public var name: String + public var kind: NodeKind + /// Owning module name (e.g. `WhereCore`). + public var module: String + public var origin: Origin + /// Owning node id (the enclosing type or module) for membership / nesting. + public var parentID: String? + /// Source file path, relative to the repo root when known. + public var file: String? + /// 1-based line of the declaration. + public var line: Int? + /// Whether the declaration introduces generic parameters. + public var isGeneric: Bool + + public init( + id: String, + name: String, + kind: NodeKind, + module: String, + origin: Origin, + parentID: String? = nil, + file: String? = nil, + line: Int? = nil, + isGeneric: Bool = false, + ) { + self.id = id + self.name = name + self.kind = kind + self.module = module + self.origin = origin + self.parentID = parentID + self.file = file + self.line = line + self.isGeneric = isGeneric + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case kind + case module + case origin + case parentID + case file + case line + case isGeneric + } + + /// Custom decoding so that hand-written and older snapshots can omit + /// naturally-defaulted fields (`isGeneric`) without failing to load. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + kind = try container.decode(NodeKind.self, forKey: .kind) + module = try container.decode(String.self, forKey: .module) + origin = try container.decode(Origin.self, forKey: .origin) + parentID = try container.decodeIfPresent(String.self, forKey: .parentID) + file = try container.decodeIfPresent(String.self, forKey: .file) + line = try container.decodeIfPresent(Int.self, forKey: .line) + isGeneric = try container.decodeIfPresent(Bool.self, forKey: .isGeneric) ?? false + } +} + +/// The flavor of a ``Node``. +public enum NodeKind: String, Codable, Sendable, CaseIterable, Hashable { + // Type declarations. + case `class` + case `struct` + case `enum` + case `protocol` + case actor + case `extension` + case typeAlias = "typealias" + case associatedType + + // Members / free declarations. + case method + case property + case initializer + case `subscript` + case enumCase + case function + + /// Grouping. + case module + + case other + + /// Whether this kind is a nominal type — the primary nodes a UML-style view + /// arranges (as opposed to members, modules, or loose declarations). + public var isType: Bool { + switch self { + case .class, .struct, .enum, .protocol, .actor, .extension, .typeAlias, + .associatedType: + true + case .method, .property, .initializer, .subscript, .enumCase, .function, + .module, .other: + false + } + } + + /// Whether this kind is a member of an enclosing type (and so hidden until + /// its owner is expanded). + public var isMember: Bool { + switch self { + case .method, .property, .initializer, .subscript, .enumCase: + true + case .class, .struct, .enum, .protocol, .actor, .extension, .typeAlias, + .associatedType, .function, .module, .other: + false + } + } +} + +/// Where a node's declaration lives relative to the repository. +public enum Origin: String, Codable, Sendable, CaseIterable, Hashable { + /// Declared in the repository's production code. + case firstParty + /// Declared in a test target of the repository. + case test + /// Declared outside the repository (Apple SDK, third-party packages). + case external +} diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json b/Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json new file mode 100644 index 0000000..cdc4f78 --- /dev/null +++ b/Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-06-16T22:00:00Z", + "repoPath": "/Users/kve/Development/Stuff2", + "commit": null, + "modules": [ + { "name": "WhereCore", "kind": "library", "dependencies": ["Foundation"] }, + { "name": "WhereUI", "kind": "library", "dependencies": ["WhereCore"] }, + { "name": "Foundation", "kind": "external", "dependencies": [] } + ], + "nodes": [ + { "id": "module:WhereCore", "name": "WhereCore", "kind": "module", "module": "WhereCore", "origin": "firstParty", "isGeneric": false }, + { "id": "module:WhereUI", "name": "WhereUI", "kind": "module", "module": "WhereUI", "origin": "firstParty", "isGeneric": false }, + { "id": "module:Foundation", "name": "Foundation", "kind": "module", "module": "Foundation", "origin": "external", "isGeneric": false }, + + { "id": "s:WhereCore10WhereStoreP", "name": "WhereStore", "kind": "protocol", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/Persistence/WhereStore.swift", "line": 17, "isGeneric": false }, + { "id": "s:WhereCore16EvidenceBlobStoreP", "name": "EvidenceBlobStore", "kind": "protocol", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/Evidence/EvidenceBlobStore.swift", "line": 10, "isGeneric": false }, + { "id": "s:WhereCore14LocationSourceP", "name": "LocationSource", "kind": "protocol", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/Location/LocationSource.swift", "line": 30, "isGeneric": false }, + { "id": "s:WhereCore13SwiftDataStoreC", "name": "SwiftDataStore", "kind": "actor", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/Persistence/SwiftDataStore.swift", "line": 44, "isGeneric": false }, + { "id": "s:WhereCore18CoreLocationSourceC", "name": "CoreLocationSource", "kind": "class", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/Location/CoreLocationSource.swift", "line": 12, "isGeneric": false }, + { "id": "s:WhereCore13WhereServicesV", "name": "WhereServices", "kind": "struct", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/WhereServices.swift", "line": 20, "isGeneric": false }, + { "id": "s:WhereCore10YearReportV", "name": "YearReport", "kind": "struct", "module": "WhereCore", "origin": "firstParty", "parentID": "module:WhereCore", "file": "Where/WhereCore/Sources/YearReport.swift", "line": 8, "isGeneric": false }, + + { "id": "s:WhereUI11WhereSessionC", "name": "WhereSession", "kind": "class", "module": "WhereUI", "origin": "firstParty", "parentID": "module:WhereUI", "file": "Where/WhereUI/Sources/Model/WhereSession.swift", "line": 19, "isGeneric": false }, + { "id": "s:WhereUI9WhereModelC", "name": "WhereModel", "kind": "class", "module": "WhereUI", "origin": "firstParty", "parentID": "module:WhereUI", "file": "Where/WhereUI/Sources/Model/WhereModel.swift", "line": 24, "isGeneric": false }, + { "id": "s:WhereUI11WhereSessionC9LoadStateO", "name": "LoadState", "kind": "enum", "module": "WhereUI", "origin": "firstParty", "parentID": "s:WhereUI11WhereSessionC", "file": "Where/WhereUI/Sources/Model/WhereSession.swift", "line": 22, "isGeneric": false }, + + { "id": "s:WhereUI11WhereSessionC8servicesvp", "name": "services", "kind": "property", "module": "WhereUI", "origin": "firstParty", "parentID": "s:WhereUI11WhereSessionC", "file": "Where/WhereUI/Sources/Model/WhereSession.swift", "line": 30 }, + { "id": "s:WhereUI11WhereSessionC6reportvp", "name": "report", "kind": "property", "module": "WhereUI", "origin": "firstParty", "parentID": "s:WhereUI11WhereSessionC", "file": "Where/WhereUI/Sources/Model/WhereSession.swift", "line": 30 }, + { "id": "s:WhereUI11WhereSessionC12startSessionyyF", "name": "startSession()", "kind": "method", "module": "WhereUI", "origin": "firstParty", "parentID": "s:WhereUI11WhereSessionC", "file": "Where/WhereUI/Sources/Model/WhereSession.swift", "line": 120 }, + { "id": "s:WhereUI9WhereModelC7sessionvp", "name": "session", "kind": "property", "module": "WhereUI", "origin": "firstParty", "parentID": "s:WhereUI9WhereModelC", "file": "Where/WhereUI/Sources/Model/WhereModel.swift", "line": 40 }, + + { "id": "c:objc(cs)NSObject", "name": "NSObject", "kind": "class", "module": "Foundation", "origin": "external", "parentID": "module:Foundation", "isGeneric": false }, + { "id": "s:10Foundation4DataV", "name": "Data", "kind": "struct", "module": "Foundation", "origin": "external", "parentID": "module:Foundation", "isGeneric": false } + ], + "edges": [ + { "id": "e1", "source": "module:WhereUI", "target": "module:WhereCore", "kind": "moduleDependency", "count": 1 }, + { "id": "e2", "source": "module:WhereCore", "target": "module:Foundation", "kind": "moduleDependency", "count": 1 }, + + { "id": "e3", "source": "s:WhereCore13SwiftDataStoreC", "target": "s:WhereCore10WhereStoreP", "kind": "conformance", "count": 1 }, + { "id": "e4", "source": "s:WhereCore13SwiftDataStoreC", "target": "s:WhereCore16EvidenceBlobStoreP", "kind": "conformance", "count": 1 }, + { "id": "e5", "source": "s:WhereCore18CoreLocationSourceC", "target": "s:WhereCore14LocationSourceP", "kind": "conformance", "count": 1 }, + { "id": "e6", "source": "s:WhereCore18CoreLocationSourceC", "target": "c:objc(cs)NSObject", "kind": "inheritance", "count": 1 }, + + { "id": "e7", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereUI11WhereSessionC9LoadStateO", "kind": "member", "count": 1 }, + { "id": "e8", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereUI11WhereSessionC8servicesvp", "kind": "member", "count": 1 }, + { "id": "e9", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereUI11WhereSessionC6reportvp", "kind": "member", "count": 1 }, + { "id": "e10", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereUI11WhereSessionC12startSessionyyF", "kind": "member", "count": 1 }, + { "id": "e11", "source": "s:WhereUI9WhereModelC", "target": "s:WhereUI9WhereModelC7sessionvp", "kind": "member", "count": 1 }, + + { "id": "e12", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereCore13WhereServicesV", "kind": "propertyType", "viaMemberID": "s:WhereUI11WhereSessionC8servicesvp", "count": 1 }, + { "id": "e13", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereCore10YearReportV", "kind": "propertyType", "viaMemberID": "s:WhereUI11WhereSessionC6reportvp", "count": 1 }, + { "id": "e14", "source": "s:WhereUI9WhereModelC", "target": "s:WhereUI11WhereSessionC", "kind": "propertyType", "viaMemberID": "s:WhereUI9WhereModelC7sessionvp", "count": 1 }, + + { "id": "e15", "source": "s:WhereCore13WhereServicesV", "target": "s:WhereCore10WhereStoreP", "kind": "propertyType", "count": 2 }, + { "id": "e16", "source": "s:WhereCore13WhereServicesV", "target": "s:WhereCore14LocationSourceP", "kind": "propertyType", "count": 1 }, + + { "id": "e17", "source": "s:WhereUI9WhereModelC", "target": "s:WhereUI11WhereSessionC", "kind": "construction", "count": 1 }, + { "id": "e18", "source": "s:WhereUI11WhereSessionC", "target": "s:WhereCore13WhereServicesV", "kind": "paramOrReturnType", "viaMemberID": "s:WhereUI11WhereSessionC12startSessionyyF", "count": 1 }, + + { "id": "e19", "source": "s:WhereCore10YearReportV", "target": "s:10Foundation4DataV", "kind": "reference", "count": 1 }, + { "id": "e20", "source": "s:WhereCore13SwiftDataStoreC", "target": "s:WhereCore10YearReportV", "kind": "paramOrReturnType", "count": 3 } + ] +} diff --git a/Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift b/Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift new file mode 100644 index 0000000..5d5b212 --- /dev/null +++ b/Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift @@ -0,0 +1,50 @@ +@testable import CodeGraphModel +import Foundation +import Testing + +struct CodeGraphModelTests { + @Test func sampleGraphDecodes() throws { + let graph = try CodeGraph.sample() + #expect(graph.schemaVersion == CodeGraph.currentSchemaVersion) + #expect(!graph.modules.isEmpty) + #expect(!graph.nodes.isEmpty) + #expect(!graph.edges.isEmpty) + } + + @Test func roundTripsThroughJSON() throws { + let graph = try CodeGraph.sample() + let restored = try CodeGraph.decoded(from: graph.encoded()) + #expect(restored == graph) + } + + @Test func everyEdgeEndpointResolvesToANode() throws { + let graph = try CodeGraph.sample() + let ids = Set(graph.nodes.map(\.id)) + for edge in graph.edges { + #expect(ids.contains(edge.source), "edge \(edge.id) has unknown source \(edge.source)") + #expect(ids.contains(edge.target), "edge \(edge.id) has unknown target \(edge.target)") + if let member = edge.viaMemberID { + #expect(ids.contains(member), "edge \(edge.id) has unknown member \(member)") + } + } + } + + @Test func everyMemberAndNestedNodeHasAParent() throws { + let graph = try CodeGraph.sample() + let ids = Set(graph.nodes.map(\.id)) + for node in graph.nodes where node.kind.isMember { + let parent = try #require(node.parentID, "member \(node.id) needs a parent") + #expect(ids.contains(parent), "member \(node.id) has unknown parent \(parent)") + } + } + + @Test func edgeKindStructuralPartitionIsTotal() { + // Every kind is classified; this guards new cases from silently + // defaulting to "not structural". + for kind in EdgeKind.allCases { + _ = kind.isStructural + } + #expect(EdgeKind.inheritance.isStructural) + #expect(!EdgeKind.propertyType.isStructural) + } +} From c387a9da09cd7d7d6678a8c247a0fcc5a1332b6d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:23:16 -0400 Subject: [PATCH 02/22] Add code-graph-extract CLI that opens the compiler index store Adds the executable target (ArgumentParser) to the CodeGraph package with the indexstore-db + swift-argument-parser dependencies. The CLI resolves libIndexStore from the active toolchain via xcrun, resolves the index store either from --index-store-path or a dedicated reproducible build (tuist generate --no-open + xcodebuild build-for-testing into .codegraph/DerivedData with COMPILER_INDEX_STORE_ENABLE=YES), and opens IndexStoreDB against it. Harvesting the graph lands next. Bumps the package macOS minimum to 14 (required by IndexStoreDB). Closes plan to-do: extractor-index. Co-authored-by: Cursor --- Tools/CodeGraph/Package.resolved | 33 +++++++++ Tools/CodeGraph/Package.swift | 15 +++- .../code-graph-extract/CodeGraphExtract.swift | 68 ++++++++++++++++++ .../code-graph-extract/ExtractError.swift | 21 ++++++ .../code-graph-extract/IndexStoreInput.swift | 72 +++++++++++++++++++ .../code-graph-extract/IndexStoreReader.swift | 41 +++++++++++ .../Sources/code-graph-extract/Shell.swift | 62 ++++++++++++++++ .../code-graph-extract/Toolchain.swift | 38 ++++++++++ 8 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 Tools/CodeGraph/Package.resolved create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/ExtractError.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/IndexStoreInput.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/IndexStoreReader.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/Shell.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/Toolchain.swift diff --git a/Tools/CodeGraph/Package.resolved b/Tools/CodeGraph/Package.resolved new file mode 100644 index 0000000..fb2211a --- /dev/null +++ b/Tools/CodeGraph/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "202103fec6cbf3d97858fad0e010652defd8c509d3af60ea84d55d4c24f3f67a", + "pins" : [ + { + "identity" : "indexstore-db", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/indexstore-db.git", + "state" : { + "branch" : "main", + "revision" : "46a7e6467c7a3e706c57181f59bd0e08e9d14937" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-lmdb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-lmdb.git", + "state" : { + "branch" : "main", + "revision" : "a4bc87807721c1fd114bf35464457e2db0d0e6c0" + } + } + ], + "version" : 3 +} diff --git a/Tools/CodeGraph/Package.swift b/Tools/CodeGraph/Package.swift index 5b3cde9..1e0f1e4 100644 --- a/Tools/CodeGraph/Package.swift +++ b/Tools/CodeGraph/Package.swift @@ -4,11 +4,16 @@ import PackageDescription let package = Package( name: "CodeGraph", platforms: [ - .macOS(.v13), + .macOS(.v14), .iOS(.v16), ], products: [ .library(name: "CodeGraphModel", targets: ["CodeGraphModel"]), + .executable(name: "code-graph-extract", targets: ["code-graph-extract"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/indexstore-db.git", branch: "main"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), ], targets: [ .target( @@ -17,6 +22,14 @@ let package = Package( .process("Resources"), ], ), + .executableTarget( + name: "code-graph-extract", + dependencies: [ + "CodeGraphModel", + .product(name: "IndexStoreDB", package: "indexstore-db"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + ), .testTarget( name: "CodeGraphModelTests", dependencies: [ diff --git a/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift new file mode 100644 index 0000000..440986b --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import CodeGraphModel +import Foundation + +@main +struct CodeGraphExtract: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "code-graph-extract", + abstract: "Extract a type/relationship graph from a Swift repo's compiler index store.", + ) + + @Option(help: "Repository root. Defaults to the current directory.") + var repo: String = FileManager.default.currentDirectoryPath + + @Option( + help: "Where to write graph.json. Defaults to /Tools/CodeGraph/.codegraph/graph.json.", + ) + var output: String? + + @Option(help: "Read an existing index store at this path instead of building.") + var indexStorePath: String? + + @Option(help: "Override the path to libIndexStore.dylib (default: active toolchain).") + var toolchainLib: String? + + @Option(help: "Xcode scheme to build for the dedicated index build.") + var scheme: String = "Stuff-Workspace" + + @Option(help: "xcodebuild destination for the dedicated index build.") + var destination: String = "platform=iOS Simulator,name=iPhone 17" + + @Option(help: "Derived-data path for the dedicated index build (holds the index store).") + var derivedData: String? + + @Flag(help: "Use an index store from a prior build; never invoke xcodebuild.") + var skipBuild = false + + func run() async throws { + let repoPath = URL(fileURLWithPath: repo).standardizedFileURL.path + let libPath = try Toolchain.libIndexStorePath(override: toolchainLib) + + let storePath = try resolveStorePath(repoPath: repoPath) + log("opening index store at \(storePath)") + let reader = try IndexStoreReader(storePath: storePath, libIndexStorePath: libPath) + log("index opened: \(reader.symbolNameCount()) symbol names") + // Harvesting the graph and writing graph.json lands in the next step. + } + + private func resolveStorePath(repoPath: String) throws -> String { + if let indexStorePath { + return URL(fileURLWithPath: indexStorePath).standardizedFileURL.path + } + let derivedDataPath = derivedData + ?? (repoPath + "/Tools/CodeGraph/.codegraph/DerivedData") + let input = IndexStoreInput( + repoPath: repoPath, + derivedDataPath: derivedDataPath, + scheme: scheme, + destination: destination, + ) + try input.ensureBuilt(skipBuild: skipBuild) + return input.indexStorePath + } + + private func log(_ message: String) { + FileHandle.standardError.write(Data("==> \(message)\n".utf8)) + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/ExtractError.swift b/Tools/CodeGraph/Sources/code-graph-extract/ExtractError.swift new file mode 100644 index 0000000..2b6f134 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/ExtractError.swift @@ -0,0 +1,21 @@ +import Foundation + +enum ExtractError: Error, CustomStringConvertible { + case libIndexStoreNotFound(String) + case indexStoreNotFound(String) + case missingWorkspace(String) + case processFailed(command: String, status: Int32, output: String) + + var description: String { + switch self { + case let .libIndexStoreNotFound(path): + "Could not find libIndexStore.dylib at \(path). Pass --toolchain-lib to override." + case let .indexStoreNotFound(path): + "No index store found at \(path). Build first, or pass --index-store-path." + case let .missingWorkspace(path): + "Expected an Xcode workspace at \(path). Run `tuist generate` first." + case let .processFailed(command, status, output): + "Command failed (exit \(status)): \(command)\n\(output)" + } + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreInput.swift b/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreInput.swift new file mode 100644 index 0000000..fbfc1cf --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreInput.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Resolves the index store the extractor reads from: either a caller-supplied +/// path, or one produced by a dedicated, reproducible build into a fixed +/// derived-data directory. +struct IndexStoreInput { + var repoPath: String + var derivedDataPath: String + var scheme: String + var destination: String + + /// Xcode writes the compiler index store under the derived-data directory. + var indexStorePath: String { + derivedDataPath + "/Index.noindex/DataStore" + } + + /// Ensure an index store exists at `indexStorePath`, building if needed. + /// + /// - Parameter skipBuild: when true, never invoke the build; require an + /// index store from a prior build to already be present. + func ensureBuilt(skipBuild: Bool) throws { + if skipBuild { + try requireStore() + return + } + try generateProject() + try build() + try requireStore() + } + + private func requireStore() throws { + guard FileManager.default.fileExists(atPath: indexStorePath) else { + throw ExtractError.indexStoreNotFound(indexStorePath) + } + } + + private func generateProject() throws { + log("tuist generate --no-open") + try Shell.runStreaming( + "/usr/bin/env", + ["mise", "exec", "--", "tuist", "generate", "--no-open"], + cwd: repoPath, + ) + } + + private func build() throws { + let workspace = repoPath + "/Stuff.xcworkspace" + guard FileManager.default.fileExists(atPath: workspace) else { + throw ExtractError.missingWorkspace(workspace) + } + log("xcodebuild build-for-testing (scheme: \(scheme))") + // `build-for-testing` compiles the libraries, the app, and the test + // bundles, so the "everything" scope (including tests) gets indexed. + try Shell.runStreaming("/usr/bin/xcrun", [ + "xcodebuild", + "build-for-testing", + "-workspace", + workspace, + "-scheme", + scheme, + "-destination", + destination, + "-derivedDataPath", + derivedDataPath, + "COMPILER_INDEX_STORE_ENABLE=YES", + ], cwd: repoPath) + } + + private func log(_ message: String) { + FileHandle.standardError.write(Data("==> \(message)\n".utf8)) + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreReader.swift b/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreReader.swift new file mode 100644 index 0000000..76aaa64 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreReader.swift @@ -0,0 +1,41 @@ +import Foundation +import IndexStoreDB + +/// Opens an `IndexStoreDB` against a store path and exposes the queries the +/// harvester builds the graph from. +struct IndexStoreReader { + let db: IndexStoreDB + + init(storePath: String, libIndexStorePath: String) throws { + guard FileManager.default.fileExists(atPath: storePath) else { + throw ExtractError.indexStoreNotFound(storePath) + } + let library = try IndexStoreLibrary(dylibPath: libIndexStorePath) + let scratchDB = NSTemporaryDirectory() + "code-graph-extract-db-" + UUID().uuidString + db = try IndexStoreDB( + storePath: storePath, + databasePath: scratchDB, + library: library, + waitUntilDoneInitializing: true, + readonly: false, + listenToUnitEvents: true, + ) + db.pollForUnitChangesAndWait(isInitialScan: true) + } + + /// Re-scan the store for new units (used by `--watch`). + func refresh() { + db.pollForUnitChangesAndWait(isInitialScan: false) + } + + /// Number of distinct symbol names in the store — a cheap sanity signal + /// that the store opened and is populated. + func symbolNameCount() -> Int { + var count = 0 + db.forEachSymbolName { _ in + count += 1 + return true + } + return count + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift b/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift new file mode 100644 index 0000000..d3b2b71 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Thin wrappers over `Process` for the few external commands the extractor +/// needs (xcrun, git, tuist, xcodebuild). +enum Shell { + /// Runs a command and returns its stdout. Throws on a non-zero exit. Use + /// only for short-lived commands whose output comfortably fits a pipe. + @discardableResult + static func capture( + _ executable: String, + _ arguments: [String], + cwd: String? = nil, + ) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let cwd { + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + } + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: outData, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw ExtractError.processFailed( + command: ([executable] + arguments).joined(separator: " "), + status: process.terminationStatus, + output: output + String(decoding: errData, as: UTF8.self), + ) + } + return output + } + + /// Runs a command inheriting the parent's stdout/stderr so long builds + /// stream their progress live. Throws on a non-zero exit. + static func runStreaming( + _ executable: String, + _ arguments: [String], + cwd: String? = nil, + ) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let cwd { + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + } + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw ExtractError.processFailed( + command: ([executable] + arguments).joined(separator: " "), + status: process.terminationStatus, + output: "(see streamed output above)", + ) + } + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/Toolchain.swift b/Tools/CodeGraph/Sources/code-graph-extract/Toolchain.swift new file mode 100644 index 0000000..f8674cd --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/Toolchain.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Locates toolchain artifacts the extractor depends on. +enum Toolchain { + /// Absolute path to the active toolchain's `libIndexStore.dylib`. The + /// library must match the toolchain that produced the index store, so by + /// default we derive it from the same `swift` that `xcrun` resolves. + static func libIndexStorePath(override: String?) throws -> String { + if let override { + guard FileManager.default.fileExists(atPath: override) else { + throw ExtractError.libIndexStoreNotFound(override) + } + return override + } + // `xcrun --find swift` -> .../XcodeDefault.xctoolchain/usr/bin/swift, and + // libIndexStore lives next door at .../usr/lib/libIndexStore.dylib. + let swiftPath = try Shell.capture("/usr/bin/xcrun", ["--find", "swift"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let usr = URL(fileURLWithPath: swiftPath) + .deletingLastPathComponent() // usr/bin + .deletingLastPathComponent() // usr + let lib = usr.appendingPathComponent("lib/libIndexStore.dylib") + guard FileManager.default.fileExists(atPath: lib.path) else { + throw ExtractError.libIndexStoreNotFound(lib.path) + } + return lib.path + } + + /// The `HEAD` commit of the repo, if it is a git working copy. + static func gitCommit(repoPath: String) -> String? { + let output = try? Shell.capture("/usr/bin/git", ["-C", repoPath, "rev-parse", "HEAD"]) + let trimmed = output?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { + return nil + } + return trimmed + } +} From d8a93db2ec043885a4484364b6dc18edb8a8d50f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:35:36 -0400 Subject: [PATCH 03/22] Harvest nodes and relationship edges from the index store Adds GraphBuilder, which walks the repo's first-party Swift files in the opened index store and assembles a CodeGraph: - definitions become typed nodes (origin first-party/test/external, parent resolved from the childOf relation, generics flagged); - structural edges come from index relations: membership (childOf), overrides (overrideOf), and inheritance/conformance, which Swift records on the base type's reference occurrence (occurrence = base, baseOf relation -> derived), classifying class bases as inheritance and protocol/raw-value bases as conformance; - data-flow edges come from reference occurrences resolved through their containedBy/calledBy container: property types, parameter/return types, construction (init calls), and generic constraints; - external symbols the repo touches are synthesized as leaf nodes, and module nodes plus moduleDependency edges are derived from observed cross-module references. The result is written atomically to .codegraph/graph.json. Validated against the tool's own package index: 282 nodes / 599 edges, correct conformance direction, zero dangling edges or parents. Closes plan to-do: extractor-harvest. Co-authored-by: Cursor --- .../code-graph-extract/CodeGraphExtract.swift | 32 +- .../code-graph-extract/GraphBuilder.swift | 370 ++++++++++++++++++ .../code-graph-extract/SymbolMapping.swift | 84 ++++ 3 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/GraphBuilder.swift create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift diff --git a/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift index 440986b..c3bf660 100644 --- a/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift +++ b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift @@ -38,12 +38,38 @@ struct CodeGraphExtract: AsyncParsableCommand { func run() async throws { let repoPath = URL(fileURLWithPath: repo).standardizedFileURL.path let libPath = try Toolchain.libIndexStorePath(override: toolchainLib) - let storePath = try resolveStorePath(repoPath: repoPath) + let outputPath = output ?? (repoPath + "/Tools/CodeGraph/.codegraph/graph.json") + log("opening index store at \(storePath)") let reader = try IndexStoreReader(storePath: storePath, libIndexStorePath: libPath) - log("index opened: \(reader.symbolNameCount()) symbol names") - // Harvesting the graph and writing graph.json lands in the next step. + try extractAndWrite(reader: reader, repoPath: repoPath, outputPath: outputPath) + } + + /// Harvest the graph from the (already opened) store and write graph.json. + func extractAndWrite(reader: IndexStoreReader, repoPath: String, outputPath: String) throws { + log("harvesting graph") + let content = GraphBuilder(db: reader.db, repoPath: repoPath).build() + let graph = CodeGraph( + generatedAt: Date(), + repoPath: repoPath, + commit: Toolchain.gitCommit(repoPath: repoPath), + modules: content.modules, + nodes: content.nodes, + edges: content.edges, + ) + try write(graph, to: outputPath) + log("wrote \(content.nodes.count) nodes, \(content.edges.count) edges, " + + "\(content.modules.count) modules -> \(outputPath)") + } + + private func write(_ graph: CodeGraph, to path: String) throws { + let url = URL(fileURLWithPath: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try graph.encoded().write(to: url, options: .atomic) } private func resolveStorePath(repoPath: String) throws -> String { diff --git a/Tools/CodeGraph/Sources/code-graph-extract/GraphBuilder.swift b/Tools/CodeGraph/Sources/code-graph-extract/GraphBuilder.swift new file mode 100644 index 0000000..5e1f8f5 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/GraphBuilder.swift @@ -0,0 +1,370 @@ +import CodeGraphModel +import Foundation +import IndexStoreDB + +/// Walks an opened index store and assembles a ``CodeGraph``. +/// +/// Two passes over the repo's first-party source files: +/// 1. definitions become nodes, and their relations become the structural +/// edges (inheritance, conformance, override, membership); +/// 2. references become the data-flow edges (property / parameter / return +/// types, construction, generic constraints). +/// External symbols the repo touches are synthesized as leaf nodes on demand. +final class GraphBuilder { + private let db: IndexStoreDB + private let repoPath: String + private let repoPrefix: String + + private var nodes: [String: Node] = [:] + private var edges: [String: Edge] = [:] + private var moduleNameCache: [String: String] = [:] + private var constructorParentCache: [String: Symbol?] = [:] + + init(db: IndexStoreDB, repoPath: String) { + self.db = db + self.repoPath = repoPath + repoPrefix = repoPath.hasSuffix("/") ? repoPath : repoPath + "/" + } + + func build() -> (modules: [Module], nodes: [Node], edges: [Edge]) { + let files = firstPartySwiftFiles() + for file in files { + harvestDefinitions(in: file) + } + for file in files { + harvestReferences(in: file) + } + let modules = finalize() + return (modules, Array(nodes.values), Array(edges.values)) + } + + // MARK: - Pass A: definitions + + private func harvestDefinitions(in file: String) { + for occ in db.symbolOccurrences(inFilePath: file) { + guard occ.roles.contains(.definition) else { continue } + let origin = SymbolMapping.origin( + path: occ.location.path, + isSystem: occ.location.isSystem, + repoPrefix: repoPrefix, + ) + guard origin != .external else { continue } + guard !occ.symbol.properties.contains(.local) else { continue } + guard let kind = SymbolMapping.nodeKind(for: occ.symbol) else { continue } + + nodes[occ.symbol.usr] = makeNode(occ: occ, kind: kind, origin: origin) + addStructuralEdges(from: occ) + addInheritanceEdges(from: occ) + } + } + + private func makeNode(occ: SymbolOccurrence, kind: NodeKind, origin: Origin) -> Node { + let module = occ.location.moduleName.isEmpty ? "Unknown" : occ.location.moduleName + return Node( + id: occ.symbol.usr, + name: occ.symbol.name, + kind: kind, + module: module, + origin: origin, + parentID: parentUSR(of: occ) ?? moduleNodeID(module), + file: relativePath(occ.location.path), + line: occ.location.line, + isGeneric: occ.symbol.properties.contains(.generic), + ) + } + + /// Override and membership edges, which the index records on the child / + /// overriding declaration's own occurrence. + private func addStructuralEdges(from occ: SymbolOccurrence) { + let source = occ.symbol.usr + for relation in occ.relations { + let related = relation.symbol + if relation.roles.contains(.overrideOf) { + ensureLeafNode(for: related) + insert(source: source, target: related.usr, kind: .override) + } + if relation.roles.contains(.childOf) { + // Parent owns this symbol; emit the membership edge parent -> child. + insert(source: related.usr, target: source, kind: .member) + } + } + } + + /// Inheritance and conformance edges. Swift records these at the *base* + /// type's reference in the inheritance clause: the occurrence's symbol is + /// the base, and a `.baseOf` relation points to the derived type. Returns + /// whether any such edge was found (so the reference pass can skip treating + /// the same occurrence as a data-flow reference). + @discardableResult + private func addInheritanceEdges(from occ: SymbolOccurrence) -> Bool { + let base = occ.symbol + var found = false + for relation in occ.relations where relation.roles.contains(.baseOf) { + let derived = relation.symbol + // Only a class base is true subclassing; protocol bases and + // raw-value bases (e.g. `enum E: String`) are conformances. + let kind: EdgeKind = base.kind == .class ? .inheritance : .conformance + ensureLeafNode(for: base) + ensureLeafNode(for: derived) + insert(source: derived.usr, target: base.usr, kind: kind) + found = true + } + return found + } + + // MARK: - Pass B: references (data flow) + + private func harvestReferences(in file: String) { + for occ in db.symbolOccurrences(inFilePath: file) { + guard occ.roles.contains(.reference), !occ.roles.contains(.definition) else { continue } + // Inheritance-clause references record conformance/inheritance; once + // handled they should not also count as a data-flow reference. + if addInheritanceEdges(from: occ) { continue } + guard let container = container(of: occ) else { continue } + guard let source = sourceInfo(forContainer: container) else { continue } + guard let (target, kind) = classify(reference: occ, container: container) + else { continue } + guard source.id != target.usr else { continue } + ensureLeafNode(for: target) + insert(source: source.id, target: target.usr, kind: kind, viaMember: source.via) + } + } + + /// The declaration the reference textually sits inside. + private func container(of occ: SymbolOccurrence) -> Symbol? { + if let contained = occ.relations.first(where: { $0.roles.contains(.containedBy) }) { + return contained.symbol + } + if let called = occ.relations.first(where: { $0.roles.contains(.calledBy) }) { + return called.symbol + } + return nil + } + + /// The graph node a reference should originate from: the owning type when + /// the container is a member, otherwise the container itself. + private func sourceInfo(forContainer container: Symbol) -> (id: String, via: String?)? { + guard let node = nodes[container.usr] else { return nil } + if node.kind.isMember { + guard let parent = node.parentID else { return nil } + return (parent, container.usr) + } + return (container.usr, nil) + } + + private func classify( + reference occ: SymbolOccurrence, + container: Symbol, + ) -> (target: Symbol, kind: EdgeKind)? { + let referenced = occ.symbol + if referenced.kind == .constructor { + guard let type = constructorParent(referenced) else { return nil } + return (type, .construction) + } + guard SymbolMapping.isType(referenced.kind) else { return nil } + let kind: EdgeKind = switch container.kind { + case .instanceProperty, .classProperty, .staticProperty, .variable, .field: + .propertyType + case .instanceMethod, .classMethod, .staticMethod, .function, .constructor, + .destructor, .conversionFunction: + occ.roles.contains(.call) ? .construction : .paramOrReturnType + case .class, .struct, .enum, .protocol, .extension, .union, .typealias: + referenced.kind == .protocol ? .genericConstraint : .reference + default: + .reference + } + return (referenced, kind) + } + + // MARK: - External leaves & lookups + + private func ensureLeafNode(for symbol: Symbol) { + guard nodes[symbol.usr] == nil else { return } + guard let kind = SymbolMapping.nodeKind(for: symbol) else { return } + let module = externalModuleName(for: symbol) + nodes[symbol.usr] = Node( + id: symbol.usr, + name: symbol.name, + kind: kind, + module: module, + origin: .external, + parentID: moduleNodeID(module), + isGeneric: symbol.properties.contains(.generic), + ) + } + + private func parentUSR(of occ: SymbolOccurrence) -> String? { + occ.relations.first { $0.roles.contains(.childOf) }?.symbol.usr + } + + private func constructorParent(_ constructor: Symbol) -> Symbol? { + if let cached = constructorParentCache[constructor.usr] { + return cached + } + var parent: Symbol? + for occ in db.occurrences(ofUSR: constructor.usr, roles: [.definition, .declaration]) { + if let rel = occ.relations.first(where: { $0.roles.contains(.childOf) }) { + parent = rel.symbol + break + } + } + constructorParentCache[constructor.usr] = parent + return parent + } + + private func externalModuleName(for symbol: Symbol) -> String { + if let cached = moduleNameCache[symbol.usr] { + return cached + } + var module = "External" + for occ in db.occurrences(ofUSR: symbol.usr, roles: [.definition, .declaration]) + where !occ.location.moduleName.isEmpty + { + module = occ.location.moduleName + break + } + moduleNameCache[symbol.usr] = module + return module + } + + // MARK: - Finalize + + private func finalize() -> [Module] { + ensureModuleNodes() + repairParents() + let dependencies = addModuleDependencyEdges() + dropDanglingEdges() + return makeModules(dependencies: dependencies) + } + + private func ensureModuleNodes() { + var origins: [String: Origin] = [:] + for node in nodes.values where node.kind != .module { + origins[node.module] = strongestOrigin(origins[node.module], node.origin) + } + for (module, origin) in origins { + let id = moduleNodeID(module) + guard nodes[id] == nil else { continue } + nodes[id] = Node(id: id, name: module, kind: .module, module: module, origin: origin) + } + } + + private func repairParents() { + for (id, node) in nodes { + guard let parent = node.parentID, nodes[parent] == nil else { continue } + var fixed = node + fixed.parentID = moduleNodeID(node.module) + nodes[id] = fixed + } + } + + /// Adds module-to-module edges for every observed cross-module relationship + /// and returns, per module, the set of modules it depends on. + private func addModuleDependencyEdges() -> [String: Set] { + var dependencies: [String: Set] = [:] + for edge in edges.values { + guard + let sourceModule = nodes[edge.source]?.module, + let targetModule = nodes[edge.target]?.module, + sourceModule != targetModule + else { continue } + dependencies[sourceModule, default: []].insert(targetModule) + } + for (source, targets) in dependencies { + for target in targets { + insert( + source: moduleNodeID(source), + target: moduleNodeID(target), + kind: .moduleDependency, + ) + } + } + return dependencies + } + + private func dropDanglingEdges() { + edges = edges.filter { nodes[$0.value.source] != nil && nodes[$0.value.target] != nil } + } + + private func makeModules(dependencies: [String: Set]) -> [Module] { + nodes.values + .filter { $0.kind == .module } + .map { node in + Module( + name: node.name, + kind: moduleKind(name: node.name, origin: node.origin), + dependencies: (dependencies[node.name] ?? []).sorted(), + ) + } + .sorted { $0.name < $1.name } + } + + private func moduleKind(name: String, origin: Origin) -> ModuleKind { + switch origin { + case .external: .external + case .test: .test + case .firstParty: name.hasSuffix("Tests") ? .test : .library + } + } + + // MARK: - Helpers + + private func insert(source: String, target: String, kind: EdgeKind, viaMember: String? = nil) { + let key = "\(source)|\(target)|\(kind.rawValue)|\(viaMember ?? "")" + if var existing = edges[key] { + existing.count += 1 + edges[key] = existing + } else { + edges[key] = Edge( + id: key, + source: source, + target: target, + kind: kind, + viaMemberID: viaMember, + ) + } + } + + private func moduleNodeID(_ module: String) -> String { + "module:\(module)" + } + + private func strongestOrigin(_ current: Origin?, _ candidate: Origin) -> Origin { + guard let current else { return candidate } + let rank: (Origin) -> Int = { origin in + switch origin { + case .firstParty: 2 + case .test: 1 + case .external: 0 + } + } + return rank(candidate) > rank(current) ? candidate : current + } + + private func relativePath(_ path: String) -> String { + path.hasPrefix(repoPrefix) ? String(path.dropFirst(repoPrefix.count)) : path + } + + private func firstPartySwiftFiles() -> [String] { + let excluded: Set = [".build", "Derived", "DerivedData", ".codegraph", ".git"] + let root = URL(fileURLWithPath: repoPath) + guard + let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + ) + else { return [] } + + var files: [String] = [] + for case let url as URL in enumerator { + if excluded.contains(url.lastPathComponent) { + enumerator.skipDescendants() + continue + } + if url.pathExtension == "swift" { + files.append(url.path) + } + } + return files + } +} diff --git a/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift new file mode 100644 index 0000000..cd9fced --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift @@ -0,0 +1,84 @@ +import CodeGraphModel +import IndexStoreDB + +/// Translates index-store symbol metadata into the graph's vocabulary. +enum SymbolMapping { + /// Maps an index symbol to a graph node kind, or `nil` for kinds we never + /// surface as nodes (parameters, accessors, generic type params, etc.). + static func nodeKind(for symbol: Symbol) -> NodeKind? { + switch symbol.kind { + case .class: + // Swift actors are indexed as classes; the index can't tell them + // apart, so they surface as `.class` (a SwiftSyntax pass could + // refine this later). + .class + case .struct: + .struct + case .enum: + .enum + case .protocol: + .protocol + case .extension: + .extension + case .union: + .struct + case .typealias: + switch symbol.subKind { + case .swiftAssociatedType: .associatedType + case .swiftGenericTypeParam: nil + default: .typeAlias + } + case .function: + .function + case .instanceMethod, .classMethod, .staticMethod, .destructor, .conversionFunction: + isAccessor(symbol.subKind) ? nil : .method + case .constructor: + .initializer + case .instanceProperty, .classProperty, .staticProperty, .variable, .field: + isAccessor(symbol.subKind) ? nil : .property + case .enumConstant: + .enumCase + case .unknown, .module, .namespace, .namespaceAlias, .macro, .parameter, .using, + .concept, .commentTag: + nil + } + } + + /// Whether the index symbol kind denotes a nominal type (the things a + /// data-flow edge can point at). + static func isType(_ kind: IndexSymbolKind) -> Bool { + switch kind { + case .class, .struct, .enum, .protocol, .extension, .typealias, .union: + true + case .unknown, .module, .namespace, .namespaceAlias, .macro, .function, .variable, + .field, .enumConstant, .instanceMethod, .classMethod, .staticMethod, + .instanceProperty, .classProperty, .staticProperty, .constructor, .destructor, + .conversionFunction, .parameter, .using, .concept, .commentTag: + false + } + } + + /// Classifies where a declaration lives relative to the repository. + static func origin(path: String, isSystem: Bool, repoPrefix: String) -> Origin { + if isSystem || path.isEmpty { + return .external + } + guard path.hasPrefix(repoPrefix) else { + return .external + } + return path.contains("/Tests/") ? .test : .firstParty + } + + private static func isAccessor(_ subKind: IndexSymbolSubKind) -> Bool { + switch subKind { + case .accessorGetter, .accessorSetter, .swiftAccessorWillSet, .swiftAccessorDidSet, + .swiftAccessorAddressor, .swiftAccessorMutableAddressor: + true + case .none, .cxxCopyConstructor, .cxxMoveConstructor, .swiftExtensionOfStruct, + .swiftExtensionOfClass, .swiftExtensionOfEnum, .swiftExtensionOfProtocol, + .swiftPrefixOperator, .swiftPostfixOperator, .swiftInfixOperator, .swiftSubscript, + .swiftAssociatedType, .swiftGenericTypeParam: + false + } + } +} From a7ccc85c321fae96912280ee3634fc43f0abc4e9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:44:08 -0400 Subject: [PATCH 04/22] Filter compiler-synthesized symbols out of the graph Real-repo extraction surfaced ~1,165 noise nodes: macro expansions and $-projected values (e.g. @Test / #Preview content, which Swift mangles with a leading $ that can't begin a user identifier) and @Observable / property-wrapper backing storage (_foo, _$observationRegistrar) whose user-facing foo already appears. Skip names that are empty or begin with $ or _ when mapping symbols to nodes. Validated against the Where workspace index: 2,865 nodes / 7,908 edges, correct inheritance/conformance directions and module dependencies, zero dangling edges or parents. Co-authored-by: Cursor --- .../Sources/code-graph-extract/SymbolMapping.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift index cd9fced..27a7224 100644 --- a/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift +++ b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift @@ -6,7 +6,8 @@ enum SymbolMapping { /// Maps an index symbol to a graph node kind, or `nil` for kinds we never /// surface as nodes (parameters, accessors, generic type params, etc.). static func nodeKind(for symbol: Symbol) -> NodeKind? { - switch symbol.kind { + guard !isSynthesizedName(symbol.name) else { return nil } + return switch symbol.kind { case .class: // Swift actors are indexed as classes; the index can't tell them // apart, so they surface as `.class` (a SwiftSyntax pass could @@ -69,6 +70,15 @@ enum SymbolMapping { return path.contains("/Tests/") ? .test : .firstParty } + /// Compiler-synthesized declarations we don't want in a hand-readable + /// graph: macro expansions and `$`-projected values (`$` can't begin a + /// user identifier in Swift), `@Observable`/property-wrapper backing + /// storage (`_foo`, `_$observationRegistrar`) whose user-facing `foo` + /// already appears, and anonymous decls. + private static func isSynthesizedName(_ name: String) -> Bool { + name.isEmpty || name.hasPrefix("$") || name.hasPrefix("_") + } + private static func isAccessor(_ subKind: IndexSymbolSubKind) -> Bool { switch subKind { case .accessorGetter, .accessorSetter, .swiftAccessorWillSet, .swiftAccessorDidSet, From e74bd9f22b5dbbfe55ce37d84cb1fe5afd3aee4f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:52:10 -0400 Subject: [PATCH 05/22] Add --watch mode that re-extracts on index-store changes DataStoreWatcher monitors the store's units directory with a DispatchSource vnode source and fires a debounced callback when a build writes new index data. In --watch mode the CLI does the initial extraction, then refreshes the IndexStoreDB (pollForUnitChangesAndWait) and rewrites graph.json on each change, handing the process to dispatchMain so the GCD source keeps running until interrupted. Verified against the Where store: touching the units directory triggers a debounced re-harvest and atomic rewrite. Closes plan to-do: extractor-watch. Co-authored-by: Cursor --- .../code-graph-extract/CodeGraphExtract.swift | 47 ++++++++++++++++- .../code-graph-extract/DataStoreWatcher.swift | 50 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 Tools/CodeGraph/Sources/code-graph-extract/DataStoreWatcher.swift diff --git a/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift index c3bf660..604022d 100644 --- a/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift +++ b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift @@ -3,7 +3,7 @@ import CodeGraphModel import Foundation @main -struct CodeGraphExtract: AsyncParsableCommand { +struct CodeGraphExtract: ParsableCommand { static let configuration = CommandConfiguration( commandName: "code-graph-extract", abstract: "Extract a type/relationship graph from a Swift repo's compiler index store.", @@ -35,7 +35,13 @@ struct CodeGraphExtract: AsyncParsableCommand { @Flag(help: "Use an index store from a prior build; never invoke xcodebuild.") var skipBuild = false - func run() async throws { + @Flag(help: "Keep running and re-extract whenever the index store changes.") + var watch = false + + @Option(help: "Debounce, in seconds, before re-extracting after a change in --watch mode.") + var watchDebounce: Double = 1.0 + + func run() throws { let repoPath = URL(fileURLWithPath: repo).standardizedFileURL.path let libPath = try Toolchain.libIndexStorePath(override: toolchainLib) let storePath = try resolveStorePath(repoPath: repoPath) @@ -44,6 +50,43 @@ struct CodeGraphExtract: AsyncParsableCommand { log("opening index store at \(storePath)") let reader = try IndexStoreReader(storePath: storePath, libIndexStorePath: libPath) try extractAndWrite(reader: reader, repoPath: repoPath, outputPath: outputPath) + + guard watch else { return } + try startWatching( + reader: reader, + storePath: storePath, + repoPath: repoPath, + outputPath: outputPath, + ) + } + + private func startWatching( + reader: IndexStoreReader, + storePath: String, + repoPath: String, + outputPath: String, + ) throws { + let unitsDirectory = unitsDirectory(storePath: storePath) + let watcher = DataStoreWatcher(directory: unitsDirectory, debounce: watchDebounce) { + do { + reader.refresh() + try extractAndWrite(reader: reader, repoPath: repoPath, outputPath: outputPath) + } catch { + FileHandle.standardError.write(Data("==> watch re-extract failed: \(error)\n".utf8)) + } + } + try watcher.start() + log("watching \(unitsDirectory) for changes (ctrl-C to stop)") + watcher.wait() + } + + private func unitsDirectory(storePath: String) -> String { + for candidate in ["\(storePath)/v5/units", "\(storePath)/units"] + where FileManager.default.fileExists(atPath: candidate) + { + return candidate + } + return storePath } /// Harvest the graph from the (already opened) store and write graph.json. diff --git a/Tools/CodeGraph/Sources/code-graph-extract/DataStoreWatcher.swift b/Tools/CodeGraph/Sources/code-graph-extract/DataStoreWatcher.swift new file mode 100644 index 0000000..51ee6cd --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/DataStoreWatcher.swift @@ -0,0 +1,50 @@ +import Darwin +import Dispatch +import Foundation + +/// Watches an index store's `units` directory and fires a debounced callback +/// whenever a build writes new index data into it. +final class DataStoreWatcher { + private let directory: String + private let debounce: TimeInterval + private let onChange: () -> Void + private let queue = DispatchQueue(label: "com.stuff.code-graph-extract.watch") + private var source: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + private var pending: DispatchWorkItem? + + init(directory: String, debounce: TimeInterval, onChange: @escaping () -> Void) { + self.directory = directory + self.debounce = debounce + self.onChange = onChange + } + + func start() throws { + fileDescriptor = open(directory, O_EVTONLY) + guard fileDescriptor >= 0 else { + throw ExtractError.indexStoreNotFound(directory) + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .rename, .delete, .extend], + queue: queue, + ) + source.setEventHandler { [weak self] in self?.scheduleChange() } + source.setCancelHandler { [fileDescriptor] in close(fileDescriptor) } + self.source = source + source.resume() + } + + /// Hand the process over to GCD so the watch keeps running. Never returns; + /// the process ends on interrupt (ctrl-C). + func wait() -> Never { + dispatchMain() + } + + private func scheduleChange() { + pending?.cancel() + let work = DispatchWorkItem { [weak self] in self?.onChange() } + pending = work + queue.asyncAfter(deadline: .now() + debounce, execute: work) + } +} From 5bed6a5c5cad9530473b9a76f4f43baf01d71607 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 16 Jun 2026 23:56:13 -0400 Subject: [PATCH 06/22] Split CodeGraphModel into its own dependency-free package Groundwork (no behavior change): move the shared graph schema into Tools/CodeGraph/CodeGraphModel as a standalone SwiftPM package with no external dependencies, and have the extractor package depend on it by path. This keeps indexstore-db (macOS-only) out of the model's dependency graph so the upcoming Mac Catalyst viewer can consume CodeGraphModel without resolving it. Extractor builds and re-extracts identically (2,865 nodes / 7,908 edges); model tests pass. Co-authored-by: Cursor --- Tools/CodeGraph/CodeGraphModel/Package.swift | 30 +++++++++++++++++++ .../Sources/CodeGraphModel/CodeGraph.swift | 0 .../CodeGraphModel/CodeGraphCoding.swift | 0 .../Sources/CodeGraphModel/Edge.swift | 0 .../Sources/CodeGraphModel/Module.swift | 0 .../Sources/CodeGraphModel/Node.swift | 0 .../Resources/sample-graph.json | 0 .../CodeGraphModelTests.swift | 0 Tools/CodeGraph/Package.swift | 20 ++++--------- 9 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 Tools/CodeGraph/CodeGraphModel/Package.swift rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/CodeGraph.swift (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/CodeGraphCoding.swift (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/Edge.swift (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/Module.swift (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/Node.swift (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Sources/CodeGraphModel/Resources/sample-graph.json (100%) rename Tools/CodeGraph/{ => CodeGraphModel}/Tests/CodeGraphModelTests/CodeGraphModelTests.swift (100%) diff --git a/Tools/CodeGraph/CodeGraphModel/Package.swift b/Tools/CodeGraph/CodeGraphModel/Package.swift new file mode 100644 index 0000000..1bd40de --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.2 +import PackageDescription + +/// Dependency-free model package shared by the code-graph-extract CLI and the +/// Catalyst viewer. Keeping it free of heavy/platform-specific dependencies +/// (e.g. indexstore-db) lets the iOS/Mac Catalyst viewer consume it cleanly. +let package = Package( + name: "CodeGraphModel", + platforms: [ + .macOS(.v13), + .iOS(.v16), + ], + products: [ + .library(name: "CodeGraphModel", targets: ["CodeGraphModel"]), + ], + targets: [ + .target( + name: "CodeGraphModel", + resources: [ + .process("Resources"), + ], + ), + .testTarget( + name: "CodeGraphModelTests", + dependencies: [ + "CodeGraphModel", + ], + ), + ], +) diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraph.swift similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/CodeGraph.swift rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraph.swift diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraphCoding.swift similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/CodeGraphCoding.swift rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraphCoding.swift diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Edge.swift similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/Edge.swift rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Edge.swift diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Module.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Module.swift similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/Module.swift rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Module.swift diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Node.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Node.swift similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/Node.swift rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Node.swift diff --git a/Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Resources/sample-graph.json similarity index 100% rename from Tools/CodeGraph/Sources/CodeGraphModel/Resources/sample-graph.json rename to Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Resources/sample-graph.json diff --git a/Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift b/Tools/CodeGraph/CodeGraphModel/Tests/CodeGraphModelTests/CodeGraphModelTests.swift similarity index 100% rename from Tools/CodeGraph/Tests/CodeGraphModelTests/CodeGraphModelTests.swift rename to Tools/CodeGraph/CodeGraphModel/Tests/CodeGraphModelTests/CodeGraphModelTests.swift diff --git a/Tools/CodeGraph/Package.swift b/Tools/CodeGraph/Package.swift index 1e0f1e4..fab53e3 100644 --- a/Tools/CodeGraph/Package.swift +++ b/Tools/CodeGraph/Package.swift @@ -1,40 +1,30 @@ // swift-tools-version: 6.2 import PackageDescription +/// The index-store extractor CLI. The shared graph schema lives in the +/// dependency-free CodeGraphModel package next door so the Catalyst viewer can +/// reuse it without pulling in indexstore-db (macOS-only). let package = Package( name: "CodeGraph", platforms: [ .macOS(.v14), - .iOS(.v16), ], products: [ - .library(name: "CodeGraphModel", targets: ["CodeGraphModel"]), .executable(name: "code-graph-extract", targets: ["code-graph-extract"]), ], dependencies: [ + .package(path: "CodeGraphModel"), .package(url: "https://github.com/apple/indexstore-db.git", branch: "main"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), ], targets: [ - .target( - name: "CodeGraphModel", - resources: [ - .process("Resources"), - ], - ), .executableTarget( name: "code-graph-extract", dependencies: [ - "CodeGraphModel", + .product(name: "CodeGraphModel", package: "CodeGraphModel"), .product(name: "IndexStoreDB", package: "indexstore-db"), .product(name: "ArgumentParser", package: "swift-argument-parser"), ], ), - .testTarget( - name: "CodeGraphModelTests", - dependencies: [ - "CodeGraphModel", - ], - ), ], ) From 8118263adcfb576ffa0cba31d3209f90aaf69eb8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:02:37 -0400 Subject: [PATCH 07/22] Add Mac Catalyst viewer that loads and hot-reloads graph.json Standalone Tuist project (Tools/CodeGraph/Viewer) for a sandboxed Mac Catalyst app depending only on the dependency-free CodeGraphModel package. GraphStore opens a user-selected graph.json via the file importer, persists access with an app-scoped security-scoped bookmark (reopened on launch), decodes it with the shared model, and a DispatchSource FileWatcher re-reads it on every change (re-arming across the extractor's atomic replaces). A placeholder summary view stands in for the interactive canvas, which lands next. Builds for Mac Catalyst (BUILD SUCCEEDED). Closes plan to-do: viewer-scaffold. Co-authored-by: Cursor --- .../Sources/App/CodeGraphViewerApp.swift | 14 ++++ .../Sources/Model/FileWatcher.swift | 65 +++++++++++++++ .../Sources/Model/GraphBookmark.swift | 55 +++++++++++++ .../Sources/Model/GraphStore.swift | 80 +++++++++++++++++++ .../Sources/Views/ContentView.swift | 53 ++++++++++++ .../Sources/Views/EmptyStateView.swift | 41 ++++++++++ .../Sources/Views/GraphSummaryView.swift | 37 +++++++++ Tools/CodeGraph/Viewer/Project.swift | 46 +++++++++++ Tools/CodeGraph/Viewer/Tuist.swift | 3 + 9 files changed, 394 insertions(+) create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/FileWatcher.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphBookmark.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphStore.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift create mode 100644 Tools/CodeGraph/Viewer/Project.swift create mode 100644 Tools/CodeGraph/Viewer/Tuist.swift diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift new file mode 100644 index 0000000..ebaaf99 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct CodeGraphViewerApp: App { + @State private var store = GraphStore() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(store) + .task { store.restore() } + } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/FileWatcher.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/FileWatcher.swift new file mode 100644 index 0000000..cbb1824 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/FileWatcher.swift @@ -0,0 +1,65 @@ +import Darwin +import Dispatch +import Foundation + +/// Watches a single file for writes and fires a debounced callback. Re-arms on +/// every change so it keeps following the path across atomic replaces (the +/// extractor writes graph.json to a temp file and renames it into place, which +/// swaps the inode the watched descriptor points at). +final class FileWatcher { + private let url: URL + private let debounce: TimeInterval + private let onChange: () -> Void + private let queue = DispatchQueue(label: "com.stuff.codegraph.viewer.filewatch") + private var source: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32 = -1 + private var pending: DispatchWorkItem? + + init(url: URL, debounce: TimeInterval, onChange: @escaping () -> Void) { + self.url = url + self.debounce = debounce + self.onChange = onChange + } + + func start() { + queue.async { [weak self] in self?.arm() } + } + + func stop() { + queue.async { [weak self] in + self?.pending?.cancel() + self?.source?.cancel() + self?.source = nil + } + } + + private func arm() { + source?.cancel() + fileDescriptor = open(url.path, O_EVTONLY) + guard fileDescriptor >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .extend, .delete, .rename, .attrib], + queue: queue, + ) + source.setEventHandler { [weak self] in self?.scheduleChange() } + source.setCancelHandler { [fileDescriptor] in close(fileDescriptor) } + self.source = source + source.resume() + } + + private func scheduleChange() { + pending?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + arm() + onChange() + } + pending = work + queue.asyncAfter(deadline: .now() + debounce, execute: work) + } + + deinit { + source?.cancel() + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphBookmark.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphBookmark.swift new file mode 100644 index 0000000..d33a301 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphBookmark.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Persists a security-scoped bookmark to the user-selected graph.json so the +/// sandboxed viewer can reopen it on the next launch without re-prompting. +struct GraphBookmark { + private let key = "graph.bookmark" + private let defaults = UserDefaults.standard + + func save(_ url: URL) { + guard let data = try? url.bookmarkData( + options: creationOptions, + includingResourceValuesForKeys: nil, + relativeTo: nil, + ) else { + return + } + defaults.set(data, forKey: key) + } + + /// Resolve the saved bookmark, refreshing it if the system reports it stale. + /// The returned URL still needs `startAccessingSecurityScopedResource()`. + func resolve() -> URL? { + guard let data = defaults.data(forKey: key) else { return nil } + var isStale = false + guard let url = try? URL( + resolvingBookmarkData: data, + options: resolutionOptions, + relativeTo: nil, + bookmarkDataIsStale: &isStale, + ) else { + defaults.removeObject(forKey: key) + return nil + } + if isStale { + save(url) + } + return url + } + + private var creationOptions: URL.BookmarkCreationOptions { + #if targetEnvironment(macCatalyst) || os(macOS) + [.withSecurityScope] + #else + [] + #endif + } + + private var resolutionOptions: URL.BookmarkResolutionOptions { + #if targetEnvironment(macCatalyst) || os(macOS) + [.withSecurityScope] + #else + [] + #endif + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphStore.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphStore.swift new file mode 100644 index 0000000..bde27bc --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/GraphStore.swift @@ -0,0 +1,80 @@ +import CodeGraphModel +import Foundation +import Observation + +/// Owns the currently-loaded graph and keeps it in sync with the file on disk. +@MainActor +@Observable +final class GraphStore { + private(set) var graph: CodeGraph? + private(set) var sourceURL: URL? + private(set) var loadError: String? + private(set) var lastLoaded: Date? + + private let bookmark = GraphBookmark() + private var watcher: FileWatcher? + private var accessedURL: URL? + + /// Reopen the previously-selected file, if any, at launch. + func restore() { + guard graph == nil, let url = bookmark.resolve() else { return } + load(url, persistBookmark: false) + } + + /// Open a freshly user-selected file and remember it for next launch. + func open(_ url: URL) { + load(url, persistBookmark: true) + } + + /// Re-read the current file (used by the watcher and the Reload command). + func reload() { + guard let url = sourceURL else { return } + decode(from: url) + } + + private func load(_ url: URL, persistBookmark: Bool) { + releaseAccess() + let accessed = url.startAccessingSecurityScopedResource() + if accessed { + accessedURL = url + } + if persistBookmark { + bookmark.save(url) + } + sourceURL = url + decode(from: url) + guard loadError == nil else { + return + } + startWatching(url) + } + + private func decode(from url: URL) { + do { + let data = try Data(contentsOf: url) + graph = try CodeGraph.decoded(from: data) + loadError = nil + lastLoaded = .now + } catch { + loadError = "Couldn't read \(url.lastPathComponent): \(error.localizedDescription)" + } + } + + private func startWatching(_ url: URL) { + watcher?.stop() + let watcher = FileWatcher(url: url, debounce: 0.3) { [weak self] in + Task { @MainActor in self?.reload() } + } + watcher.start() + self.watcher = watcher + } + + private func releaseAccess() { + watcher?.stop() + watcher = nil + if let accessedURL { + accessedURL.stopAccessingSecurityScopedResource() + } + accessedURL = nil + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift new file mode 100644 index 0000000..33c2e6d --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -0,0 +1,53 @@ +import CodeGraphModel +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @Environment(GraphStore.self) private var store + @State private var isImporting = false + + var body: some View { + NavigationStack { + content + .navigationTitle(store.sourceURL?.lastPathComponent ?? "CodeGraph") + .toolbar { toolbar } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.json], + ) { result in + if case let .success(url) = result { + store.open(url) + } + } + } + } + + @ViewBuilder + private var content: some View { + if let graph = store.graph { + GraphSummaryView(graph: graph) + } else { + EmptyStateView(error: store.loadError) { isImporting = true } + } + } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + Button { + isImporting = true + } label: { + Label("Open graph.json", systemImage: "folder") + } + } + if store.graph != nil { + ToolbarItem(placement: .primaryAction) { + Button { + store.reload() + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } + } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift new file mode 100644 index 0000000..2f9e900 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct EmptyStateView: View { + let error: String? + let onOpen: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 56)) + .foregroundStyle(.tint) + VStack(spacing: 6) { + Text("No graph loaded") + .font(.title2.weight(.semibold)) + Text( + "Open a graph.json produced by code-graph-extract to explore the repository's types and relationships.", + ) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } + Button(action: onOpen) { + Label("Open graph.json", systemImage: "folder") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + if let error { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + .padding(.top, 8) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } + } + .padding(40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift new file mode 100644 index 0000000..d7b3dfd --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift @@ -0,0 +1,37 @@ +import CodeGraphModel +import SwiftUI + +/// Placeholder rendering that proves a graph loaded and hot-reloads. The +/// interactive canvas replaces this in a later step. +struct GraphSummaryView: View { + let graph: CodeGraph + + var body: some View { + List { + Section("Overview") { + LabeledContent("Modules", value: graph.modules.count.formatted()) + LabeledContent("Nodes", value: graph.nodes.count.formatted()) + LabeledContent("Edges", value: graph.edges.count.formatted()) + if let commit = graph.commit { + LabeledContent("Commit", value: String(commit.prefix(8))) + } + LabeledContent( + "Generated", + value: graph.generatedAt.formatted(date: .abbreviated, time: .shortened), + ) + } + + Section("Modules") { + ForEach(graph.modules.sorted { $0.name < $1.name }) { module in + HStack { + Text(module.name) + Spacer() + Text(module.kind.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } +} diff --git a/Tools/CodeGraph/Viewer/Project.swift b/Tools/CodeGraph/Viewer/Project.swift new file mode 100644 index 0000000..4db584c --- /dev/null +++ b/Tools/CodeGraph/Viewer/Project.swift @@ -0,0 +1,46 @@ +import ProjectDescription + +/// Standalone Mac Catalyst viewer for the graph.json that code-graph-extract +/// emits. It depends only on the dependency-free CodeGraphModel package next +/// door, and reads a user-selected graph.json through a security-scoped +/// bookmark so the app stays sandboxed. +let project = Project( + name: "CodeGraphViewer", + options: .options( + defaultKnownRegions: ["en"], + developmentRegion: "en", + ), + packages: [ + .local(path: .relativeToManifest("../CodeGraphModel")), + ], + settings: .settings(base: [ + "SUPPORTS_MACCATALYST": "YES", + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "TARGETED_DEVICE_FAMILY": "2", + "MARKETING_VERSION": "1.0", + "CURRENT_PROJECT_VERSION": "1", + ]), + targets: [ + .target( + name: "CodeGraphViewer", + destinations: [.macCatalyst], + product: .app, + bundleId: "com.stuff.codegraph.viewer", + deploymentTargets: .iOS("26.0"), + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": .dictionary([:]), + "CFBundleDisplayName": .string("CodeGraph"), + "UIApplicationSupportsIndirectInputEvents": .boolean(true), + ]), + sources: ["CodeGraphViewer/Sources/**"], + entitlements: .dictionary([ + "com.apple.security.app-sandbox": .boolean(true), + "com.apple.security.files.user-selected.read-only": .boolean(true), + "com.apple.security.files.bookmarks.app-scope": .boolean(true), + ]), + dependencies: [ + .package(product: "CodeGraphModel"), + ], + ), + ], +) diff --git a/Tools/CodeGraph/Viewer/Tuist.swift b/Tools/CodeGraph/Viewer/Tuist.swift new file mode 100644 index 0000000..ff8e64f --- /dev/null +++ b/Tools/CodeGraph/Viewer/Tuist.swift @@ -0,0 +1,3 @@ +import ProjectDescription + +let config = Config() From c7ef37501bc835953b139de1d5c77b2ab441049e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:06:19 -0400 Subject: [PATCH 08/22] Add force-directed layout engine actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayoutEngine settles nodes with Fruchterman–Reingold forces off the main actor: grid-bucketed repulsion (keeps the full graph ~O(n) per step), edge spring attraction, and a gentle pull toward each module's centroid for clustering. Seeding is a deterministic phyllotaxis spiral so a given graph always settles identically, dragged nodes can be pinned (held fixed while still exerting forces), and iteration count adapts to graph size with temperature cooling. Validated standalone: deterministic and finite positions, pins held, sensible spread; ~2,900 nodes / 8,000 edges settle in ~0.5s. Closes plan to-do: viewer-layout. Co-authored-by: Cursor --- .../Sources/Layout/LayoutEngine.swift | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift new file mode 100644 index 0000000..f05e072 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift @@ -0,0 +1,303 @@ +import CoreGraphics +import Foundation + +/// A self-contained description of what to lay out, decoupled from the model so +/// it can cross the actor boundary cheaply. +struct LayoutInput { + struct Node { + var id: String + var module: String + } + + struct Edge { + var source: String + var target: String + var weight: Double + } + + var nodes: [Node] + var edges: [Edge] + /// Nodes the user dragged: held fixed but still repel/attract others. + var pinned: [String: CGPoint] + var size: CGSize +} + +/// Force-directed (Fruchterman–Reingold) layout with module-centroid gravity, +/// grid-accelerated repulsion, deterministic seeding, and pinned nodes. +/// +/// Runs off the main actor so a large graph can settle without janking the UI. +actor LayoutEngine { + /// Settle the graph and return a position per node id. + func layout(_ input: LayoutInput, iterations: Int? = nil) -> [String: CGPoint] { + let nodes = input.nodes + let count = nodes.count + guard count > 0 else { return [:] } + + let width = max(Double(input.size.width), 400) + let height = max(Double(input.size.height), 400) + let centerX = width / 2 + let centerY = height / 2 + // Ideal edge length; floored so dense clusters stay legible. + let k = max(36.0, (width * height / Double(count)).squareRoot()) + let steps = iterations ?? adaptiveIterations(for: count) + + var index = [String: Int](minimumCapacity: count) + for (i, node) in nodes.enumerated() { + index[node.id] = i + } + + var posX = [Double](repeating: 0, count: count) + var posY = [Double](repeating: 0, count: count) + seedPositions( + into: &posX, + &posY, + centerX: centerX, + centerY: centerY, + radius: min(centerX, centerY), + ) + + var pinnedIndices = Set() + for (id, point) in input.pinned { + guard let i = index[id] else { continue } + posX[i] = Double(point.x) + posY[i] = Double(point.y) + pinnedIndices.insert(i) + } + + let edges: [(Int, Int, Double)] = input.edges.compactMap { edge in + guard let source = index[edge.source], let target = index[edge.target], + source != target + else { + return nil + } + return (source, target, edge.weight) + } + + var dispX = [Double](repeating: 0, count: count) + var dispY = [Double](repeating: 0, count: count) + var temperature = min(width, height) / 6 + let cooling = pow(2.0 / max(temperature, 2), 1.0 / Double(max(steps, 1))) + var rng = SplitMix64(seed: 0xD1B5_4A32_D192_ED03) + + for _ in 0 ..< steps { + for i in 0 ..< count { + dispX[i] = 0 + dispY[i] = 0 + } + applyRepulsion(posX: posX, posY: posY, dispX: &dispX, dispY: &dispY, k: k, rng: &rng) + applyAttraction( + edges: edges, + posX: posX, + posY: posY, + dispX: &dispX, + dispY: &dispY, + k: k, + ) + applyModuleGravity(nodes: nodes, posX: posX, posY: posY, dispX: &dispX, dispY: &dispY) + integrate( + posX: &posX, + posY: &posY, + dispX: dispX, + dispY: dispY, + pinned: pinnedIndices, + temperature: temperature, + ) + temperature = max(temperature * cooling, 1) + } + + var result = [String: CGPoint](minimumCapacity: count) + for i in 0 ..< count { + result[nodes[i].id] = CGPoint(x: posX[i], y: posY[i]) + } + return result + } + + // MARK: - Forces + + /// Grid-bucketed repulsion: each node is pushed only by others in its own + /// and adjacent cells, which keeps the cost ~O(n) for spread-out graphs + /// while distant pairs contribute negligibly anyway. + private func applyRepulsion( + posX: [Double], + posY: [Double], + dispX: inout [Double], + dispY: inout [Double], + k: Double, + rng: inout SplitMix64, + ) { + let count = posX.count + let cellSize = k + var grid = [Cell: [Int]]() + for i in 0 ..< count { + grid[Cell(posX[i], posY[i], cellSize), default: []].append(i) + } + + let k2 = k * k + for i in 0 ..< count { + let cell = Cell(posX[i], posY[i], cellSize) + for neighbor in cell.neighborhood() { + guard let bucket = grid[neighbor] else { continue } + for j in bucket where j != i { + var dx = posX[i] - posX[j] + var dy = posY[i] - posY[j] + var dist2 = dx * dx + dy * dy + if dist2 < 0.01 { + dx = rng.nextUnit() - 0.5 + dy = rng.nextUnit() - 0.5 + dist2 = dx * dx + dy * dy + 0.01 + } + let dist = dist2.squareRoot() + let force = k2 / dist + dispX[i] += dx / dist * force + dispY[i] += dy / dist * force + } + } + } + } + + private func applyAttraction( + edges: [(Int, Int, Double)], + posX: [Double], + posY: [Double], + dispX: inout [Double], + dispY: inout [Double], + k: Double, + ) { + for (source, target, weight) in edges { + let dx = posX[source] - posX[target] + let dy = posY[source] - posY[target] + let dist = max((dx * dx + dy * dy).squareRoot(), 0.01) + let force = dist * dist / k * weight + let fx = dx / dist * force + let fy = dy / dist * force + dispX[source] -= fx + dispY[source] -= fy + dispX[target] += fx + dispY[target] += fy + } + } + + private func applyModuleGravity( + nodes: [LayoutInput.Node], + posX: [Double], + posY: [Double], + dispX: inout [Double], + dispY: inout [Double], + ) { + var sumX = [String: Double]() + var sumY = [String: Double]() + var counts = [String: Int]() + for i in 0 ..< nodes.count { + let module = nodes[i].module + sumX[module, default: 0] += posX[i] + sumY[module, default: 0] += posY[i] + counts[module, default: 0] += 1 + } + let pull = 0.06 + for i in 0 ..< nodes.count { + let module = nodes[i].module + guard let n = counts[module], n > 0 else { continue } + let centroidX = sumX[module]! / Double(n) + let centroidY = sumY[module]! / Double(n) + dispX[i] += (centroidX - posX[i]) * pull + dispY[i] += (centroidY - posY[i]) * pull + } + } + + private func integrate( + posX: inout [Double], + posY: inout [Double], + dispX: [Double], + dispY: [Double], + pinned: Set, + temperature: Double, + ) { + for i in 0 ..< posX.count where !pinned.contains(i) { + let magnitude = (dispX[i] * dispX[i] + dispY[i] * dispY[i]).squareRoot() + guard magnitude > 0 else { continue } + let limited = min(magnitude, temperature) + posX[i] += dispX[i] / magnitude * limited + posY[i] += dispY[i] / magnitude * limited + } + } + + // MARK: - Seeding & tuning + + private func seedPositions( + into posX: inout [Double], + _ posY: inout [Double], + centerX: Double, + centerY: Double, + radius: Double, + ) { + // A deterministic phyllotaxis spiral spreads the seed evenly and + // reproducibly, so the same graph always settles the same way. + let golden = Double.pi * (3 - 5.0.squareRoot()) + let count = posX.count + for i in 0 ..< count { + let t = Double(i) + 0.5 + let r = radius * (t / Double(count)).squareRoot() + let angle = t * golden + posX[i] = centerX + r * cos(angle) + posY[i] = centerY + r * sin(angle) + } + } + + private func adaptiveIterations(for count: Int) -> Int { + switch count { + case ..<200: 500 + case ..<600: 360 + case ..<1500: 240 + default: 160 + } + } +} + +/// Integer grid cell used to bucket nodes for neighborhood repulsion. +private struct Cell: Hashable { + var x: Int + var y: Int + + init(_ px: Double, _ py: Double, _ size: Double) { + x = Int((px / size).rounded(.down)) + y = Int((py / size).rounded(.down)) + } + + init(x: Int, y: Int) { + self.x = x + self.y = y + } + + func neighborhood() -> [Cell] { + var cells = [Cell]() + cells.reserveCapacity(9) + for dx in -1 ... 1 { + for dy in -1 ... 1 { + cells.append(Cell(x: x + dx, y: y + dy)) + } + } + return cells + } +} + +/// Small, fast, deterministic PRNG so seeding and jitter are reproducible. +struct SplitMix64 { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + + /// A reproducible double in [0, 1). + mutating func nextUnit() -> Double { + Double(next() >> 11) * (1.0 / 9_007_199_254_740_992.0) + } +} From d8617cff2cdfc1a44ed0fb437e0c12e2c76b0e6f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:17:55 -0400 Subject: [PATCH 09/22] Render the interactive graph canvas Replace the placeholder summary with a real graph view driven by GraphViewModel: a Canvas draws edges (colored and dashed per kind, with direction arrowheads and selection emphasis) and nodes render as positioned chips (glyph + name, kind color, pin/generic/expand affordances). Adds pan, pinch-zoom, fit-to-content, tap-to-select with a neighbor-dimming focus, drag-to-reposition-and-pin, context menus, and expand/collapse of a type's members. An inspector panel lists the selected node's declaration and its incoming/outgoing relationships, each row jumping to the other end. Layout reruns off the main actor on any visibility change; a sensible default edge-kind set keeps the first view readable. Builds for Mac Catalyst (BUILD SUCCEEDED). Closes plan to-do: viewer-render. Co-authored-by: Cursor --- .../Sources/ViewModel/GraphViewModel.swift | 243 ++++++++++++++ .../Sources/Views/ContentView.swift | 23 +- .../Sources/Views/GraphCanvasView.swift | 313 ++++++++++++++++++ .../Sources/Views/GraphStyle.swift | 91 +++++ .../Sources/Views/GraphSummaryView.swift | 37 --- .../Sources/Views/InspectorView.swift | 112 +++++++ .../Sources/Views/NodeChipView.swift | 68 ++++ 7 files changed, 849 insertions(+), 38 deletions(-) create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphStyle.swift delete mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift new file mode 100644 index 0000000..1ca34e0 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -0,0 +1,243 @@ +import CodeGraphModel +import CoreGraphics +import Foundation +import Observation +import SwiftUI + +/// `Edge` is also a SwiftUI type, so use a distinct alias for the model's edge +/// in the view layer (which imports SwiftUI) to avoid the ambiguity. +typealias GraphEdge = CodeGraphModel.Edge + +/// Drives the canvas: which nodes/edges are visible, where they sit, what's +/// selected/expanded, and re-running the layout when any of that changes. +@MainActor +@Observable +final class GraphViewModel { + let graph: CodeGraph + + private(set) var nodesByID: [String: Node] + private let childrenByParent: [String: [Node]] + private let outgoingByID: [String: [GraphEdge]] + private let incomingByID: [String: [GraphEdge]] + + /// Filters (a fuller filter UI lands in the next step; these defaults give a + /// readable first view: first-party types, members collapsed). + var showExternal = false { + didSet { invalidate() } + } + + var showModules = false { + didSet { invalidate() } + } + + var showTests = true { + didSet { invalidate() } + } + + var includedEdgeKinds: Set = GraphViewModel + .defaultEdgeKinds + { + didSet { invalidate() } + } + + var nameQuery = "" { + didSet { invalidate() } + } + + /// A readable starting set: the classic "is-a"/ownership edges plus the two + /// most informative data-flow kinds. The rest are opt-in via filters. + static let defaultEdgeKinds: Set = [ + .inheritance, + .conformance, + .propertyType, + .construction, + .member, + .override, + ] + + private(set) var expanded: Set = [] + var selection: String? + + /// Drives the inspector presentation off the selection so the selection + /// stays the single source of truth (no closure-based bindings in views). + var isInspectorPresented: Bool { + get { selection != nil } + set { if !newValue { selection = nil } } + } + + private(set) var positions: [String: CGPoint] = [:] + private(set) var pinned: [String: CGPoint] = [:] + private(set) var isLayingOut = false + + private(set) var visibleNodes: [Node] = [] + private(set) var visibleEdges: [GraphEdge] = [] + private var visibleIDs: Set = [] + + let canvasSize = CGSize(width: 2800, height: 2000) + private let engine = LayoutEngine() + private var layoutTask: Task? + + init(graph: CodeGraph) { + self.graph = graph + var byID = [String: Node](minimumCapacity: graph.nodes.count) + var children = [String: [Node]]() + for node in graph.nodes { + byID[node.id] = node + } + for node in graph.nodes where node.kind.isMember { + if let parent = node.parentID { + children[parent, default: []].append(node) + } + } + var outgoing = [String: [GraphEdge]]() + var incoming = [String: [GraphEdge]]() + for edge in graph.edges { + outgoing[edge.source, default: []].append(edge) + incoming[edge.target, default: []].append(edge) + } + nodesByID = byID + childrenByParent = children + outgoingByID = outgoing + incomingByID = incoming + rebuildVisible() + } + + // MARK: - Lookups + + func node(_ id: String) -> Node? { + nodesByID[id] + } + + func position(_ id: String) -> CGPoint? { + positions[id] + } + + func outgoing(_ id: String) -> [GraphEdge] { + outgoingByID[id] ?? [] + } + + func incoming(_ id: String) -> [GraphEdge] { + incomingByID[id] ?? [] + } + + func memberCount(_ id: String) -> Int { + childrenByParent[id]?.count ?? 0 + } + + func isExpanded(_ id: String) -> Bool { + expanded.contains(id) + } + + func isPinned(_ id: String) -> Bool { + pinned[id] != nil + } + + // MARK: - Interaction + + func select(_ id: String?) { + selection = id + } + + func toggleExpanded(_ id: String) { + guard memberCount(id) > 0 else { return } + if expanded.contains(id) { + expanded.remove(id) + } else { + expanded.insert(id) + } + invalidate() + } + + /// Live position update while dragging (no relayout). + func drag(_ id: String, to point: CGPoint) { + positions[id] = point + } + + /// Finish a drag: pin the node where it landed and re-settle the rest. + func endDrag(_ id: String, at point: CGPoint) { + positions[id] = point + pinned[id] = point + relayout() + } + + func unpin(_ id: String) { + pinned.removeValue(forKey: id) + relayout() + } + + func unpinAll() { + pinned.removeAll() + relayout() + } + + // MARK: - Visibility + + func isVisible(_ node: Node) -> Bool { + if node.kind == .module { + return showModules && matchesQuery(node) && matchesOrigin(node) + } + if node.kind.isMember { + guard let parent = node.parentID, expanded.contains(parent) else { return false } + return matchesOrigin(node) + } + return matchesQuery(node) && matchesOrigin(node) + } + + private func matchesOrigin(_ node: Node) -> Bool { + switch node.origin { + case .firstParty: true + case .external: showExternal + case .test: showTests + } + } + + private func matchesQuery(_ node: Node) -> Bool { + guard !nameQuery.isEmpty else { return true } + return node.name.localizedCaseInsensitiveContains(nameQuery) + } + + private func rebuildVisible() { + let nodes = graph.nodes.filter(isVisible) + let ids = Set(nodes.map(\.id)) + let edges = graph.edges.filter { + includedEdgeKinds.contains($0.kind) && ids.contains($0.source) && ids + .contains($0.target) + } + visibleNodes = nodes + visibleIDs = ids + visibleEdges = edges + } + + /// Recompute the visible set and re-run the layout. + func invalidate() { + rebuildVisible() + relayout() + } + + // MARK: - Layout + + func relayout() { + layoutTask?.cancel() + let input = LayoutInput( + nodes: visibleNodes.map { .init(id: $0.id, module: $0.module) }, + edges: visibleEdges.map { + .init( + source: $0.source, + target: $0.target, + weight: $0.kind.isStructural ? 2.0 : 1.0, + ) + }, + pinned: pinned.filter { visibleIDs.contains($0.key) }, + size: canvasSize, + ) + isLayingOut = true + layoutTask = Task { [engine] in + let result = await engine.layout(input) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.35)) { + positions = result + } + isLayingOut = false + } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift index 33c2e6d..3d0cc9b 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -25,7 +25,8 @@ struct ContentView: View { @ViewBuilder private var content: some View { if let graph = store.graph { - GraphSummaryView(graph: graph) + GraphContainer(graph: graph) + .id(graph.generatedAt) } else { EmptyStateView(error: store.loadError) { isImporting = true } } @@ -51,3 +52,23 @@ struct ContentView: View { } } } + +/// Owns the per-graph view model and hosts the canvas + inspector. Keyed by the +/// graph's timestamp so a hot reload rebuilds it from the new snapshot. +private struct GraphContainer: View { + @State private var model: GraphViewModel + + init(graph: CodeGraph) { + _model = State(initialValue: GraphViewModel(graph: graph)) + } + + var body: some View { + @Bindable var model = model + GraphCanvasView(model: model) + .inspector(isPresented: $model.isInspectorPresented) { + InspectorView(model: model, nodeID: model.selection ?? "") + .inspectorColumnWidth(min: 250, ideal: 310, max: 440) + } + .task { model.relayout() } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift new file mode 100644 index 0000000..46eebbf --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -0,0 +1,313 @@ +import CodeGraphModel +import SwiftUI + +/// The interactive graph: a Canvas draws the edges, positioned chips draw the +/// nodes, and gestures pan / zoom / select / drag-to-pin on top. +struct GraphCanvasView: View { + @Bindable var model: GraphViewModel + + @State private var scale: CGFloat = 1 + @State private var offset: CGSize = .zero + @GestureState private var gestureZoom: CGFloat = 1 + @GestureState private var gesturePan: CGSize = .zero + @State private var dragStart: (id: String, point: CGPoint)? + @State private var didFit = false + + private var liveScale: CGFloat { + scale * gestureZoom + } + + private var liveOffset: CGSize { + CGSize(width: offset.width + gesturePan.width, height: offset.height + gesturePan.height) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .topLeading) { + background + content + .scaleEffect(liveScale) + .offset(liveOffset) + } + .clipped() + .simultaneousGesture(zoomGesture) + .overlay(alignment: .top) { settlingIndicator } + .overlay(alignment: .bottomTrailing) { ZoomControls( + scale: $scale, + onFit: { fit(in: geo.size) }, + ) } + .overlay(alignment: .bottomLeading) { legend } + .onChange(of: model.positions.isEmpty) { _, isEmpty in + if !isEmpty, !didFit { + fit(in: geo.size) + didFit = true + } + } + .onChange(of: model.graph) { _, _ in didFit = false } + } + } + + private var background: some View { + Rectangle() + .fill(Color(uiColor: .systemBackground)) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { model.select(nil) } + .gesture(panGesture) + } + + private var content: some View { + ZStack(alignment: .topLeading) { + edgeCanvas + .allowsHitTesting(false) + nodeLayer + } + .frame( + width: model.canvasSize.width, + height: model.canvasSize.height, + alignment: .topLeading, + ) + } + + private var edgeCanvas: some View { + Canvas { context, _ in + let selection = model.selection + for edge in model.visibleEdges { + guard + let from = model.positions[edge.source], + let to = model.positions[edge.target] + else { continue } + let incident = selection == nil || edge.source == selection || edge + .target == selection + draw(edge: edge, from: from, to: to, emphasized: incident, in: context) + } + } + .frame(width: model.canvasSize.width, height: model.canvasSize.height) + } + + private func draw( + edge: GraphEdge, + from: CGPoint, + to: CGPoint, + emphasized: Bool, + in context: GraphicsContext, + ) { + let base = GraphStyle.color(for: edge.kind) + let color = base.opacity(emphasized ? 0.7 : 0.08) + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke( + path, + with: .color(color), + style: StrokeStyle( + lineWidth: GraphStyle.lineWidth(for: edge.kind), + lineCap: .round, + dash: GraphStyle.dash(for: edge.kind), + ), + ) + if emphasized { + drawArrowhead(from: from, to: to, color: base.opacity(0.7), in: context) + } + } + + private func drawArrowhead( + from: CGPoint, + to: CGPoint, + color: Color, + in context: GraphicsContext, + ) { + let dx = to.x - from.x + let dy = to.y - from.y + let length = (dx * dx + dy * dy).squareRoot() + guard length > 28 else { return } + let ux = dx / length + let uy = dy / length + // Back off the arrow so it sits at the target chip's edge, not under it. + let tip = CGPoint(x: to.x - ux * 16, y: to.y - uy * 16) + let size: CGFloat = 7 + let left = CGPoint( + x: tip.x - ux * size - uy * size * 0.6, + y: tip.y - uy * size + ux * size * 0.6, + ) + let right = CGPoint( + x: tip.x - ux * size + uy * size * 0.6, + y: tip.y - uy * size - ux * size * 0.6, + ) + var head = Path() + head.move(to: tip) + head.addLine(to: left) + head.addLine(to: right) + head.closeSubpath() + context.fill(head, with: .color(color)) + } + + private var nodeLayer: some View { + ForEach(model.visibleNodes) { node in + if let point = model.positions[node.id] { + chip(for: node) + .position(point) + } + } + } + + private func chip(for node: Node) -> some View { + let selected = model.selection == node.id + let dimmed = model.selection != nil && !selected && !isNeighbor( + node.id, + of: model.selection!, + ) + return NodeChipView( + node: node, + isSelected: selected, + isPinned: model.isPinned(node.id), + memberCount: node.kind.isType ? model.memberCount(node.id) : 0, + isExpanded: model.isExpanded(node.id), + isDimmed: dimmed, + onToggleExpand: { model.toggleExpanded(node.id) }, + ) + .onTapGesture { model.select(node.id) } + .gesture(nodeDrag(node)) + .contextMenu { nodeMenu(node) } + } + + @ViewBuilder + private func nodeMenu(_ node: Node) -> some View { + if node.kind.isType, model.memberCount(node.id) > 0 { + Button(model.isExpanded(node.id) ? "Collapse members" : "Expand members") { + model.toggleExpanded(node.id) + } + } + if model.isPinned(node.id) { + Button("Unpin") { model.unpin(node.id) } + } + } + + private func nodeDrag(_ node: Node) -> some Gesture { + DragGesture(minimumDistance: 3) + .onChanged { value in + if dragStart?.id != node.id { + dragStart = (node.id, model.positions[node.id] ?? .zero) + } + model.drag(node.id, to: dragged(value)) + } + .onEnded { value in + model.endDrag(node.id, at: dragged(value)) + dragStart = nil + } + } + + private func dragged(_ value: DragGesture.Value) -> CGPoint { + let start = dragStart?.point ?? .zero + return CGPoint( + x: start.x + value.translation.width / scale, + y: start.y + value.translation.height / scale, + ) + } + + // MARK: - Gestures + + private var zoomGesture: some Gesture { + MagnifyGesture() + .updating($gestureZoom) { value, state, _ in state = value.magnification } + .onEnded { value in scale = clamp(scale * value.magnification) } + } + + private var panGesture: some Gesture { + DragGesture() + .updating($gesturePan) { value, state, _ in state = value.translation } + .onEnded { value in + offset.width += value.translation.width + offset.height += value.translation.height + } + } + + // MARK: - Helpers + + private func isNeighbor(_ id: String, of selected: String) -> Bool { + if id == selected { return true } + for edge in model.outgoing(selected) where edge.target == id { + return true + } + for edge in model.incoming(selected) where edge.source == id { + return true + } + return false + } + + private func clamp(_ value: CGFloat) -> CGFloat { + min(max(value, 0.15), 3.0) + } + + private func fit(in viewport: CGSize) { + let points = Array(model.positions.values) + guard !points.isEmpty else { return } + let xs = points.map(\.x) + let ys = points.map(\.y) + let minX = xs.min()! + let maxX = xs.max()! + let minY = ys.min()! + let maxY = ys.max()! + let pad: CGFloat = 100 + let boxWidth = max(maxX - minX, 1) + pad + let boxHeight = max(maxY - minY, 1) + pad + let fitted = clamp(min(viewport.width / boxWidth, viewport.height / boxHeight)) + let boxCenter = CGPoint(x: (minX + maxX) / 2, y: (minY + maxY) / 2) + let canvasCenter = CGPoint(x: model.canvasSize.width / 2, y: model.canvasSize.height / 2) + scale = fitted + offset = CGSize( + width: viewport.width / 2 - (canvasCenter.x + (boxCenter.x - canvasCenter.x) * fitted), + height: viewport + .height / 2 - (canvasCenter.y + (boxCenter.y - canvasCenter.y) * fitted), + ) + } + + @ViewBuilder + private var settlingIndicator: some View { + if model.isLayingOut { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Settling \(model.visibleNodes.count) nodes…") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.thinMaterial, in: Capsule()) + .padding(.top, 10) + } + } + + private var legend: some View { + Text("\(model.visibleNodes.count) nodes · \(model.visibleEdges.count) edges") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.thinMaterial, in: Capsule()) + .padding(12) + } +} + +private struct ZoomControls: View { + @Binding var scale: CGFloat + let onFit: () -> Void + + var body: some View { + VStack(spacing: 8) { + button("plus.magnifyingglass") { scale = min(scale * 1.25, 3.0) } + button("minus.magnifyingglass") { scale = max(scale / 1.25, 0.15) } + button("arrow.up.left.and.arrow.down.right", action: onFit) + } + .padding(10) + } + + private func button(_ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.body) + .frame(width: 34, height: 34) + } + .buttonStyle(.bordered) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphStyle.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphStyle.swift new file mode 100644 index 0000000..bd4c65b --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphStyle.swift @@ -0,0 +1,91 @@ +import CodeGraphModel +import SwiftUI + +/// Visual vocabulary for the graph: colors, glyphs, and line styling that make +/// node kinds and edge kinds distinguishable at a glance. +enum GraphStyle { + static func color(for kind: NodeKind) -> Color { + switch kind { + case .class: .blue + case .struct: .green + case .enum: .orange + case .protocol: .purple + case .actor: .pink + case .extension: .teal + case .typeAlias: .mint + case .associatedType: .indigo + case .method: .cyan + case .property: .gray + case .initializer: .brown + case .subscript: .gray + case .enumCase: .yellow + case .function: .cyan + case .module: .secondary + case .other: .gray + } + } + + static func symbol(for kind: NodeKind) -> String { + switch kind { + case .class: "c.square.fill" + case .struct: "s.square.fill" + case .enum: "e.square.fill" + case .protocol: "p.square.fill" + case .actor: "a.square.fill" + case .extension: "puzzlepiece.extension.fill" + case .typeAlias: "equal.square.fill" + case .associatedType: "a.square" + case .method: "function" + case .property: "circle.fill" + case .initializer: "wand.and.stars" + case .subscript: "list.bullet.indent" + case .enumCase: "smallcircle.filled.circle" + case .function: "function" + case .module: "shippingbox.fill" + case .other: "questionmark.square" + } + } + + static func color(for origin: Origin) -> Color { + switch origin { + case .firstParty: .primary + case .test: .orange + case .external: .secondary + } + } + + static func color(for kind: EdgeKind) -> Color { + switch kind { + case .inheritance: .blue + case .conformance: .purple + case .override: .indigo + case .member: .gray + case .propertyType: .green + case .paramOrReturnType: .teal + case .construction: .orange + case .genericConstraint: .pink + case .associatedType: .mint + case .moduleDependency: .secondary + case .reference: .gray + } + } + + /// Dash pattern for an edge: structural edges are solid, data-flow edges + /// dashed, so usage relationships read as lighter than "is-a"/ownership. + static func dash(for kind: EdgeKind) -> [CGFloat] { + switch kind { + case .inheritance, .conformance, .override, .member, .moduleDependency: [] + case .propertyType, .paramOrReturnType, .construction, .genericConstraint, + .associatedType, .reference: [5, 4] + } + } + + static func lineWidth(for kind: EdgeKind) -> CGFloat { + switch kind { + case .inheritance, .conformance: 2.0 + case .override, .member, .moduleDependency: 1.5 + case .propertyType, .paramOrReturnType, .construction, .genericConstraint, + .associatedType, .reference: 1.2 + } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift deleted file mode 100644 index d7b3dfd..0000000 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphSummaryView.swift +++ /dev/null @@ -1,37 +0,0 @@ -import CodeGraphModel -import SwiftUI - -/// Placeholder rendering that proves a graph loaded and hot-reloads. The -/// interactive canvas replaces this in a later step. -struct GraphSummaryView: View { - let graph: CodeGraph - - var body: some View { - List { - Section("Overview") { - LabeledContent("Modules", value: graph.modules.count.formatted()) - LabeledContent("Nodes", value: graph.nodes.count.formatted()) - LabeledContent("Edges", value: graph.edges.count.formatted()) - if let commit = graph.commit { - LabeledContent("Commit", value: String(commit.prefix(8))) - } - LabeledContent( - "Generated", - value: graph.generatedAt.formatted(date: .abbreviated, time: .shortened), - ) - } - - Section("Modules") { - ForEach(graph.modules.sorted { $0.name < $1.name }) { module in - HStack { - Text(module.name) - Spacer() - Text(module.kind.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } -} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift new file mode 100644 index 0000000..6a5d379 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift @@ -0,0 +1,112 @@ +import CodeGraphModel +import SwiftUI + +/// Details for the selected node: its declaration and the relationships that +/// flow in and out of it, each row jumping to the other end. +struct InspectorView: View { + let model: GraphViewModel + let nodeID: String + + var body: some View { + if let node = model.node(nodeID) { + List { + declaration(node) + relationships( + title: "Outgoing", + edges: model.outgoing(node.id), + otherID: { $0.target }, + leadingIsKind: true, + ) + relationships( + title: "Incoming", + edges: model.incoming(node.id), + otherID: { $0.source }, + leadingIsKind: false, + ) + } + .listStyle(.sidebar) + } else { + ContentUnavailableView("Nothing selected", systemImage: "cursorarrow.rays") + } + } + + private func declaration(_ node: Node) -> some View { + Section { + HStack(spacing: 8) { + Image(systemName: GraphStyle.symbol(for: node.kind)) + .foregroundStyle(GraphStyle.color(for: node.kind)) + Text(node.name).font(.headline) + } + LabeledContent("Kind", value: node.kind.rawValue) + LabeledContent("Module", value: node.module) + LabeledContent("Origin", value: node.origin.rawValue) + if node.isGeneric { + LabeledContent("Generic", value: "yes") + } + if node.kind.isType, model.memberCount(node.id) > 0 { + LabeledContent("Members", value: model.memberCount(node.id).formatted()) + } + if let file = node.file { + LabeledContent("File") { + Text(node.line.map { "\(file):\($0)" } ?? file) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.trailing) + } + } + } + } + + @ViewBuilder + private func relationships( + title: String, + edges: [GraphEdge], + otherID: @escaping (GraphEdge) -> String, + leadingIsKind: Bool, + ) -> some View { + let grouped = Dictionary(grouping: edges, by: \.kind) + .sorted { $0.key.rawValue < $1.key.rawValue } + if !edges.isEmpty { + Section("\(title) (\(edges.count))") { + ForEach(grouped, id: \.key) { kind, kindEdges in + ForEach(kindEdges) { edge in + relationshipRow( + edge: edge, + otherID: otherID(edge), + showKind: leadingIsKind ? kind : kind, + ) + } + } + } + } + } + + private func relationshipRow( + edge: GraphEdge, + otherID: String, + showKind: EdgeKind, + ) -> some View { + Button { + model.select(otherID) + } label: { + HStack(spacing: 8) { + Circle() + .fill(GraphStyle.color(for: showKind)) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(model.node(otherID)?.name ?? otherID) + .lineLimit(1) + Text(showKind.rawValue) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if edge.count > 1 { + Text("×\(edge.count)").font(.caption2).foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift new file mode 100644 index 0000000..e771f17 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift @@ -0,0 +1,68 @@ +import CodeGraphModel +import SwiftUI + +/// A single node rendered as a rounded "chip": glyph + name, with affordances +/// for expanding members, and selection / pin emphasis. +struct NodeChipView: View { + let node: Node + let isSelected: Bool + let isPinned: Bool + let memberCount: Int + let isExpanded: Bool + let isDimmed: Bool + let onToggleExpand: () -> Void + + private var accent: Color { + GraphStyle.color(for: node.kind) + } + + var body: some View { + HStack(spacing: 5) { + Image(systemName: GraphStyle.symbol(for: node.kind)) + .font(.caption2) + .foregroundStyle(accent) + Text(node.name) + .font(.caption.weight(.medium)) + .lineLimit(1) + if node.isGeneric { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 7)) + .foregroundStyle(.secondary) + } + if isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.orange) + } + if memberCount > 0 { + Button(action: onToggleExpand) { + Image(systemName: isExpanded ? "chevron.down.circle.fill" : + "chevron.right.circle") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background { + Capsule(style: .continuous) + .fill(Color(uiColor: .systemBackground)) + .overlay { + Capsule(style: .continuous) + .strokeBorder( + accent.opacity(node.origin == .external ? 0.45 : 0.9), + lineWidth: isSelected ? 2.5 : 1.2, + ) + } + } + .opacity(isDimmed ? 0.28 : (node.origin == .external ? 0.85 : 1)) + .shadow( + color: isSelected ? accent.opacity(0.55) : .black.opacity(0.12), + radius: isSelected ? 7 : 2, + y: 1, + ) + .fixedSize() + } +} From b37e7f545b28d707775caa8d8a2e0c7efb4f4cb5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:22:43 -0400 Subject: [PATCH 10/22] Add client-side filtering and focus A filter popover drives the visible set: name search, scope toggles (tests / external / module nodes), per-node-kind and per-edge-kind inclusion, and per-module hide/show, plus a Reset. Selecting a node and choosing Focus (context menu, inspector, or toolbar) restricts the canvas to that node's N-hop neighborhood over the included edges, with an adjustable depth. The view model gains the filter state, a BFS focus neighborhood, and toggle/reset helpers; visibility now also respects node kind and module. Builds for Mac Catalyst (BUILD SUCCEEDED). Closes plan to-do: viewer-filter. Co-authored-by: Cursor --- .../Sources/ViewModel/GraphViewModel.swift | 135 +++++++++++++++++- .../Sources/Views/ContentView.swift | 34 +++++ .../Sources/Views/FilterPanel.swift | 94 ++++++++++++ .../Sources/Views/GraphCanvasView.swift | 5 +- .../Sources/Views/InspectorView.swift | 3 + 5 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index 1ca34e0..62eb40a 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -40,10 +40,25 @@ final class GraphViewModel { didSet { invalidate() } } + var includedNodeKinds: Set = GraphViewModel.filterableKinds { + didSet { invalidate() } + } + + var hiddenModules: Set = [] { + didSet { invalidate() } + } + var nameQuery = "" { didSet { invalidate() } } + /// When set, only the focus root and nodes within `focusDepth` hops of it + /// (over the included edges) are shown. + private(set) var focusRoot: String? + var focusDepth = 1 { + didSet { if focusRoot != nil { invalidate() } } + } + /// A readable starting set: the classic "is-a"/ownership edges plus the two /// most informative data-flow kinds. The rest are opt-in via filters. static let defaultEdgeKinds: Set = [ @@ -55,6 +70,19 @@ final class GraphViewModel { .override, ] + /// Primary (non-member, non-module) node kinds the kind filter governs. + static let filterableKinds: Set = [ + .class, + .struct, + .enum, + .protocol, + .actor, + .extension, + .typeAlias, + .associatedType, + .function, + ] + private(set) var expanded: Set = [] var selection: String? @@ -73,6 +101,7 @@ final class GraphViewModel { private(set) var visibleEdges: [GraphEdge] = [] private var visibleIDs: Set = [] + let allModuleNames: [String] let canvasSize = CGSize(width: 2800, height: 2000) private let engine = LayoutEngine() private var layoutTask: Task? @@ -99,6 +128,7 @@ final class GraphViewModel { childrenByParent = children outgoingByID = outgoing incomingByID = incoming + allModuleNames = Set(graph.nodes.map(\.module)).sorted() rebuildVisible() } @@ -170,17 +200,78 @@ final class GraphViewModel { relayout() } + // MARK: - Filtering + + var isFocused: Bool { + focusRoot != nil + } + + var focusName: String? { + focusRoot.flatMap { nodesByID[$0]?.name } + } + + func focus(on id: String) { + focusRoot = id + invalidate() + } + + func clearFocus() { + focusRoot = nil + invalidate() + } + + func toggleEdgeKind(_ kind: EdgeKind) { + if includedEdgeKinds.contains(kind) { + includedEdgeKinds.remove(kind) + } else { + includedEdgeKinds.insert(kind) + } + } + + func toggleNodeKind(_ kind: NodeKind) { + if includedNodeKinds.contains(kind) { + includedNodeKinds.remove(kind) + } else { + includedNodeKinds.insert(kind) + } + } + + func toggleModuleHidden(_ module: String) { + if hiddenModules.contains(module) { + hiddenModules.remove(module) + } else { + hiddenModules.insert(module) + } + } + + func resetFilters() { + showExternal = false + showTests = true + showModules = false + includedEdgeKinds = Self.defaultEdgeKinds + includedNodeKinds = Self.filterableKinds + hiddenModules = [] + nameQuery = "" + focusRoot = nil + focusDepth = 1 + invalidate() + } + // MARK: - Visibility - func isVisible(_ node: Node) -> Bool { + private func passesFilters(_ node: Node) -> Bool { + guard !hiddenModules.contains(node.module), matchesOrigin(node) else { return false } if node.kind == .module { - return showModules && matchesQuery(node) && matchesOrigin(node) + return showModules && matchesQuery(node) } if node.kind.isMember { guard let parent = node.parentID, expanded.contains(parent) else { return false } - return matchesOrigin(node) + return true + } + if Self.filterableKinds.contains(node.kind), !includedNodeKinds.contains(node.kind) { + return false } - return matchesQuery(node) && matchesOrigin(node) + return matchesQuery(node) } private func matchesOrigin(_ node: Node) -> Bool { @@ -197,8 +288,13 @@ final class GraphViewModel { } private func rebuildVisible() { - let nodes = graph.nodes.filter(isVisible) - let ids = Set(nodes.map(\.id)) + let allowed = graph.nodes.filter(passesFilters) + var ids = Set(allowed.map(\.id)) + var nodes = allowed + if let root = focusRoot { + ids = focusNeighborhood(root: root, depth: focusDepth, allowed: ids) + nodes = graph.nodes.filter { ids.contains($0.id) } + } let edges = graph.edges.filter { includedEdgeKinds.contains($0.kind) && ids.contains($0.source) && ids .contains($0.target) @@ -208,6 +304,33 @@ final class GraphViewModel { visibleEdges = edges } + /// Breadth-first set of node ids reachable from `root` within `depth` hops, + /// traversing only included edges into allowed nodes (root always included). + private func focusNeighborhood(root: String, depth: Int, allowed: Set) -> Set { + var visited: Set = [root] + var frontier = [root] + for _ in 0 ..< max(depth, 0) { + var next = [String]() + for id in frontier { + for edge in outgoingByID[id] ?? [] where includedEdgeKinds.contains(edge.kind) + && allowed.contains(edge.target) && !visited.contains(edge.target) + { + visited.insert(edge.target) + next.append(edge.target) + } + for edge in incomingByID[id] ?? [] where includedEdgeKinds.contains(edge.kind) + && allowed.contains(edge.source) && !visited.contains(edge.source) + { + visited.insert(edge.source) + next.append(edge.source) + } + } + if next.isEmpty { break } + frontier = next + } + return visited + } + /// Recompute the visible set and re-run the layout. func invalidate() { rebuildVisible() diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift index 3d0cc9b..cdf0c5b 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -57,6 +57,7 @@ struct ContentView: View { /// graph's timestamp so a hot reload rebuilds it from the new snapshot. private struct GraphContainer: View { @State private var model: GraphViewModel + @State private var showingFilters = false init(graph: CodeGraph) { _model = State(initialValue: GraphViewModel(graph: graph)) @@ -69,6 +70,39 @@ private struct GraphContainer: View { InspectorView(model: model, nodeID: model.selection ?? "") .inspectorColumnWidth(min: 250, ideal: 310, max: 440) } + .toolbar { toolbar } + .popover(isPresented: $showingFilters) { + FilterPanel(model: model) + } .task { model.relayout() } } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + if model.isFocused { + ToolbarItem(placement: .secondaryAction) { + Button { + model.clearFocus() + } label: { + Label("Clear focus", systemImage: "scope") + } + } + } + if !model.pinned.isEmpty { + ToolbarItem(placement: .secondaryAction) { + Button { + model.unpinAll() + } label: { + Label("Unpin all", systemImage: "pin.slash") + } + } + } + ToolbarItem(placement: .primaryAction) { + Button { + showingFilters = true + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease.circle") + } + } + } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift new file mode 100644 index 0000000..bda9fb8 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift @@ -0,0 +1,94 @@ +import CodeGraphModel +import SwiftUI + +/// Controls which nodes and edges the canvas shows: scope toggles, per-kind and +/// per-module inclusion, name search, and the focus neighborhood. +struct FilterPanel: View { + @Bindable var model: GraphViewModel + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Search names", text: $model.nameQuery) + .textFieldStyle(.roundedBorder) + } + Section("Scope") { + Toggle("First-party tests", isOn: $model.showTests) + Toggle("External symbols", isOn: $model.showExternal) + Toggle("Module nodes", isOn: $model.showModules) + } + if model.isFocused { + Section("Focus") { + LabeledContent("Centered on", value: model.focusName ?? "—") + Stepper("Depth: \(model.focusDepth)", value: $model.focusDepth, in: 1 ... 5) + Button("Clear focus", role: .destructive) { model.clearFocus() } + } + } + Section("Node kinds") { + ForEach(sortedNodeKinds, id: \.self) { kind in + toggleRow( + kind.rawValue, + color: GraphStyle.color(for: kind), + isOn: model.includedNodeKinds.contains(kind), + ) { + model.toggleNodeKind(kind) + } + } + } + Section("Edge kinds") { + ForEach(EdgeKind.allCases, id: \.self) { kind in + toggleRow( + kind.rawValue, + color: GraphStyle.color(for: kind), + isOn: model.includedEdgeKinds.contains(kind), + ) { + model.toggleEdgeKind(kind) + } + } + } + Section("Modules") { + ForEach(model.allModuleNames, id: \.self) { module in + toggleRow( + module, + color: .secondary, + isOn: !model.hiddenModules.contains(module), + ) { + model.toggleModuleHidden(module) + } + } + } + } + .navigationTitle("Filters") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Reset") { model.resetFilters() } + } + } + } + .frame(minWidth: 340, minHeight: 520) + } + + private func toggleRow( + _ title: String, + color: Color, + isOn: Bool, + action: @escaping () -> Void, + ) -> some View { + Button(action: action) { + HStack(spacing: 10) { + Circle().fill(color).frame(width: 9, height: 9) + Text(title).foregroundStyle(.primary) + Spacer() + Image(systemName: isOn ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isOn ? Color.accentColor : Color.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var sortedNodeKinds: [NodeKind] { + GraphViewModel.filterableKinds.sorted { $0.rawValue < $1.rawValue } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift index 46eebbf..0e3dd6d 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -173,13 +173,16 @@ struct GraphCanvasView: View { @ViewBuilder private func nodeMenu(_ node: Node) -> some View { + Button("Focus on \(node.name)", systemImage: "scope") { + model.focus(on: node.id) + } if node.kind.isType, model.memberCount(node.id) > 0 { Button(model.isExpanded(node.id) ? "Collapse members" : "Expand members") { model.toggleExpanded(node.id) } } if model.isPinned(node.id) { - Button("Unpin") { model.unpin(node.id) } + Button("Unpin", systemImage: "pin.slash") { model.unpin(node.id) } } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift index 6a5d379..28acf52 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift @@ -55,6 +55,9 @@ struct InspectorView: View { .multilineTextAlignment(.trailing) } } + Button("Focus neighborhood", systemImage: "scope") { + model.focus(on: node.id) + } } } From acee963544f1589df6a17decf9cf0ccc9ed61ae4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:27:06 -0400 Subject: [PATCH 11/22] Persist pins, filters, and named saved views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewerState captures the arrangement (pinned positions keyed by USR, filters, expand/focus) and is stored per repo path in the app container. On load — including hot reloads after a re-extract — it's restored and reconciled against the new graph (pins/expanded/focus for vanished symbols are dropped). Changes autosave on a short debounce. A Views menu saves the current arrangement under a name and applies or deletes saved views. Builds for Mac Catalyst with no warnings. Closes plan to-do: viewer-persist. Co-authored-by: Cursor --- .../Sources/Model/ViewerState.swift | 59 ++++++++++++ .../Sources/ViewModel/GraphViewModel.swift | 92 ++++++++++++++++++- .../Sources/Views/ContentView.swift | 36 ++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift new file mode 100644 index 0000000..651668a --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift @@ -0,0 +1,59 @@ +import CodeGraphModel +import CoreGraphics +import Foundation + +/// A serializable snapshot of everything the viewer remembers about how a graph +/// is arranged and filtered: pinned positions (keyed by USR), the active +/// filters, and expand/focus state. +struct ViewerState: Codable { + var pins: [String: CGPoint] = [:] + var expanded: Set = [] + var focusRoot: String? + var focusDepth = 1 + var showExternal = false + var showTests = true + var showModules = false + var includedEdgeKinds: Set = GraphViewModel.defaultEdgeKinds + var includedNodeKinds: Set = GraphViewModel.filterableKinds + var hiddenModules: Set = [] + var nameQuery = "" +} + +/// A user-named arrangement they can return to. +struct SavedView: Codable, Identifiable { + var id = UUID() + var name: String + var state: ViewerState +} + +/// What gets persisted per repository: the working state plus saved views. +struct ViewerRecord: Codable { + var current = ViewerState() + var savedViews: [SavedView] = [] +} + +/// Stores a ``ViewerRecord`` per repository in the app container (UserDefaults). +/// Keyed by repo path so pointing the viewer at different repos keeps their +/// arrangements separate. +struct ViewerPersistence { + private let defaults = UserDefaults.standard + + func load(repoPath: String) -> ViewerRecord { + guard + let data = defaults.data(forKey: key(repoPath)), + let record = try? JSONDecoder().decode(ViewerRecord.self, from: data) + else { + return ViewerRecord() + } + return record + } + + func save(_ record: ViewerRecord, repoPath: String) { + guard let data = try? JSONEncoder().encode(record) else { return } + defaults.set(data, forKey: key(repoPath)) + } + + private func key(_ repoPath: String) -> String { + "viewerRecord:\(repoPath)" + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index 62eb40a..95bd821 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -61,7 +61,7 @@ final class GraphViewModel { /// A readable starting set: the classic "is-a"/ownership edges plus the two /// most informative data-flow kinds. The rest are opt-in via filters. - static let defaultEdgeKinds: Set = [ + nonisolated static let defaultEdgeKinds: Set = [ .inheritance, .conformance, .propertyType, @@ -71,7 +71,7 @@ final class GraphViewModel { ] /// Primary (non-member, non-module) node kinds the kind filter governs. - static let filterableKinds: Set = [ + nonisolated static let filterableKinds: Set = [ .class, .struct, .enum, @@ -106,6 +106,14 @@ final class GraphViewModel { private let engine = LayoutEngine() private var layoutTask: Task? + private let persistence = ViewerPersistence() + private var record = ViewerRecord() + private(set) var savedViews: [SavedView] = [] + /// Suppresses relayout/persist side effects while a batch of state is being + /// applied (restore / apply saved view). + private var isBatching = false + private var saveTask: Task? + init(graph: CodeGraph) { self.graph = graph var byID = [String: Node](minimumCapacity: graph.nodes.count) @@ -129,6 +137,9 @@ final class GraphViewModel { outgoingByID = outgoing incomingByID = incoming allModuleNames = Set(graph.nodes.map(\.module)).sorted() + record = persistence.load(repoPath: graph.repoPath) + savedViews = record.savedViews + apply(record.current) rebuildVisible() } @@ -188,16 +199,91 @@ final class GraphViewModel { positions[id] = point pinned[id] = point relayout() + scheduleSave() } func unpin(_ id: String) { pinned.removeValue(forKey: id) relayout() + scheduleSave() } func unpinAll() { pinned.removeAll() relayout() + scheduleSave() + } + + // MARK: - Persistence & saved views + + func saveCurrentView(named name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + record.savedViews.append(SavedView(name: trimmed, state: captureState())) + savedViews = record.savedViews + persistNow() + } + + func applyView(_ view: SavedView) { + apply(view.state) + rebuildVisible() + relayout() + scheduleSave() + } + + func deleteView(_ view: SavedView) { + record.savedViews.removeAll { $0.id == view.id } + savedViews = record.savedViews + persistNow() + } + + /// Apply a persisted state to the current graph, dropping references to + /// symbols that no longer exist (reconciliation after a re-extract). + private func apply(_ state: ViewerState) { + isBatching = true + pinned = state.pins.filter { nodesByID[$0.key] != nil } + expanded = state.expanded.filter { nodesByID[$0] != nil } + focusRoot = state.focusRoot.flatMap { nodesByID[$0] != nil ? $0 : nil } + focusDepth = state.focusDepth + showExternal = state.showExternal + showTests = state.showTests + showModules = state.showModules + includedEdgeKinds = state.includedEdgeKinds + includedNodeKinds = state.includedNodeKinds + hiddenModules = state.hiddenModules + nameQuery = state.nameQuery + isBatching = false + } + + private func captureState() -> ViewerState { + ViewerState( + pins: pinned, + expanded: expanded, + focusRoot: focusRoot, + focusDepth: focusDepth, + showExternal: showExternal, + showTests: showTests, + showModules: showModules, + includedEdgeKinds: includedEdgeKinds, + includedNodeKinds: includedNodeKinds, + hiddenModules: hiddenModules, + nameQuery: nameQuery, + ) + } + + private func scheduleSave() { + guard !isBatching else { return } + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + self?.persistNow() + } + } + + private func persistNow() { + record.current = captureState() + persistence.save(record, repoPath: graph.repoPath) } // MARK: - Filtering @@ -333,8 +419,10 @@ final class GraphViewModel { /// Recompute the visible set and re-run the layout. func invalidate() { + guard !isBatching else { return } rebuildVisible() relayout() + scheduleSave() } // MARK: - Layout diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift index cdf0c5b..b210970 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -58,6 +58,8 @@ struct ContentView: View { private struct GraphContainer: View { @State private var model: GraphViewModel @State private var showingFilters = false + @State private var showingSaveView = false + @State private var newViewName = "" init(graph: CodeGraph) { _model = State(initialValue: GraphViewModel(graph: graph)) @@ -74,6 +76,16 @@ private struct GraphContainer: View { .popover(isPresented: $showingFilters) { FilterPanel(model: model) } + .alert("Save view", isPresented: $showingSaveView) { + TextField("Name", text: $newViewName) + Button("Save") { + model.saveCurrentView(named: newViewName) + newViewName = "" + } + Button("Cancel", role: .cancel) { newViewName = "" } + } message: { + Text("Remember the current filters, pins, and focus.") + } .task { model.relayout() } } @@ -97,6 +109,9 @@ private struct GraphContainer: View { } } } + ToolbarItem(placement: .primaryAction) { + viewsMenu + } ToolbarItem(placement: .primaryAction) { Button { showingFilters = true @@ -105,4 +120,25 @@ private struct GraphContainer: View { } } } + + private var viewsMenu: some View { + Menu { + Button("Save Current View…", systemImage: "plus") { + showingSaveView = true + } + if !model.savedViews.isEmpty { + Divider() + ForEach(model.savedViews) { view in + Menu(view.name) { + Button("Apply", systemImage: "checkmark") { model.applyView(view) } + Button("Delete", systemImage: "trash", role: .destructive) { + model.deleteView(view) + } + } + } + } + } label: { + Label("Views", systemImage: "rectangle.stack") + } + } } From e520d4eaf00efe6f326020c9ff66492e13207023 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 17 Jun 2026 00:29:57 -0400 Subject: [PATCH 12/22] Add run.sh, README, and .gitignore for CodeGraph run.sh wires the loop end to end: build the extractor, extract the graph (tuist generate + build-for-testing + harvest), then build and open the Catalyst viewer. It refuses --watch (which blocks) and points at the standalone watch invocation instead. README documents the two-process architecture, the dependency-free model split, the extractor flags, the viewer's features, and the node/edge data model. .gitignore drops .codegraph/ (dedicated build + graph.json) and generated Xcode artifacts. Closes plan to-do: glue-docs. Co-authored-by: Cursor --- Tools/CodeGraph/.gitignore | 8 +++ Tools/CodeGraph/README.md | 132 +++++++++++++++++++++++++++++++++++++ Tools/CodeGraph/run.sh | 63 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 Tools/CodeGraph/.gitignore create mode 100644 Tools/CodeGraph/README.md create mode 100755 Tools/CodeGraph/run.sh diff --git a/Tools/CodeGraph/.gitignore b/Tools/CodeGraph/.gitignore new file mode 100644 index 0000000..4a3b9ab --- /dev/null +++ b/Tools/CodeGraph/.gitignore @@ -0,0 +1,8 @@ +# code-graph-extract output: the dedicated build's DerivedData + graph.json. +.codegraph/ + +# SwiftPM / Tuist build products (the Xcode project/workspace are generated). +.build/ +Derived/ +*.xcodeproj +*.xcworkspace diff --git a/Tools/CodeGraph/README.md b/Tools/CodeGraph/README.md new file mode 100644 index 0000000..4d556cd --- /dev/null +++ b/Tools/CodeGraph/README.md @@ -0,0 +1,132 @@ +# CodeGraph + +A UML-style visualizer for how the types in this repo fit together — +inheritance, conformance, ownership, construction, and other data flows — +rendered as an interactive, filterable graph you can rearrange and save. + +It is two pieces that talk through a single JSON file: + +| Piece | What it is | Where | +|-------|------------|-------| +| `code-graph-extract` | A macOS command-line tool that reads the compiler **index store** and emits `graph.json`. | [`Sources/code-graph-extract`](Sources/code-graph-extract) | +| `CodeGraphViewer` | A standalone **Mac Catalyst** SwiftUI app that loads `graph.json` and draws it. | [`Viewer`](Viewer) | +| `CodeGraphModel` | The dependency-free data model both sides share (`Node`, `Edge`, `CodeGraph`). | [`CodeGraphModel`](CodeGraphModel) | + +The model lives in its own package with **no dependencies** so the sandboxed +Catalyst app can consume it without pulling in `indexstore-db` (which is +macOS/host-only). The extractor and the viewer never link against each other — +they only agree on the shape of `graph.json`. + +## Quick start + +```bash +Tools/CodeGraph/run.sh +``` + +That builds the extractor, runs `tuist generate` + `xcodebuild +build-for-testing` to produce a fresh index, harvests the graph into +`Tools/CodeGraph/.codegraph/graph.json`, then builds and opens the viewer. +Pick the printed `graph.json` from the open panel **once** — the app keeps a +security-scoped bookmark and reopens it automatically next launch. + +Re-running anything that rewrites `graph.json` (the script, or watch mode +below) hot-reloads the open viewer. + +> Requires macOS with Xcode 26+ and the toolchain pinned in [`.mise.toml`](../../.mise.toml) +> (Tuist). The viewer is iOS 26 / Mac Catalyst, matching the rest of the repo. + +## The extractor + +`code-graph-extract` resolves an index store, opens it with `IndexStoreDB`, +walks every symbol and relation, and writes `graph.json` atomically. + +By default it does a **dedicated, reproducible build** into a fixed +derived-data directory (`.codegraph/DerivedData`) so it never disturbs your +Xcode build, then reads the index store Xcode wrote there. + +```bash +swift build -c release --product code-graph-extract +.build/release/code-graph-extract --repo /path/to/repo +``` + +Useful flags (`--help` for the full list): + +| Flag | Purpose | +|------|---------| +| `--repo ` | Repository root (default: current directory). | +| `--output ` | Where to write `graph.json` (default: `/Tools/CodeGraph/.codegraph/graph.json`). | +| `--scheme ` | Scheme for the dedicated build (default: `Stuff-Workspace`). | +| `--destination ` | `xcodebuild` destination (default: an iPhone 17 simulator). | +| `--index-store-path ` | Read an existing index store instead of building. | +| `--skip-build` | Reuse the index store from a prior dedicated build; never invoke `xcodebuild`. | +| `--watch` | Stay running and re-extract whenever the index store changes. | +| `--watch-debounce ` | Settle time before re-extracting in watch mode (default: `1.0`). | + +### Watch mode + +```bash +.build/release/code-graph-extract --repo "$PWD" --watch +``` + +This blocks and rewrites `graph.json` each time the index store's `units` +directory changes — i.e. every time you build in Xcode. Leave the viewer open +and it follows along live. (`run.sh` is one-shot and will point you here if you +pass `--watch`.) + +## The viewer + +`CodeGraphViewer` renders edges in a SwiftUI `Canvas` and nodes as interactive +chips laid out by a force-directed engine (Fruchterman–Reingold with +grid-accelerated repulsion, module-centroid gravity, a deterministic seed, and +pinned nodes). Highlights: + +- **Pan / zoom**, click to select, and an **inspector** listing a node's + declaration plus incoming/outgoing relationships (each row jumps to the other + end). +- **Drag to pin** a node where you want it; the rest re-settles around it. +- **Expand/collapse** a type to reveal its members. +- **Focus** on a node to show only its N-hop neighborhood. +- **Filters**: by module, node kind, edge kind, origin (first-party / test / + external), and a name search. +- **Saved views**: name the current arrangement (pins + filters + focus) and + return to it. State is stored per repo path in the app container and + reconciled against re-extracted graphs (references to vanished symbols are + dropped). + +### Open it in Xcode + +```bash +cd Tools/CodeGraph/Viewer && mise exec -- tuist generate --no-open +open CodeGraphViewer.xcworkspace +``` + +## Data model + +`graph.json` decodes to a [`CodeGraph`](CodeGraphModel/Sources/CodeGraphModel/CodeGraph.swift): +a list of `modules`, `nodes`, and `edges`, plus the `repoPath`, git `commit`, +and `generatedAt` timestamp. + +A **node** has a stable `id` (the compiler USR for symbols, `module:` for +modules), a `name`, a `module`, an `origin`, an optional `parentID` / +`file` / `line`, and a `kind`: + +`class` · `struct` · `enum` · `protocol` · `actor` · `extension` · `typealias` · +`associatedType` · `method` · `property` · `initializer` · `subscript` · +`enumCase` · `function` · `module` · `other` + +An **edge** is directed (`source` → `target`), records how many occurrences +collapsed into it (`count`), an optional `viaMemberID` (the member the relation +flowed through), and a `kind`: + +`inheritance` · `conformance` · `override` · `member` · `propertyType` · +`paramOrReturnType` · `construction` · `genericConstraint` · `associatedType` · +`moduleDependency` · `reference` + +Structural kinds (`inheritance`, `conformance`, `override`, `member`, +`moduleDependency`) form the UML backbone; the rest are usage / data-flow +references. + +## Layout + +Everything `.codegraph/` (the dedicated build's derived data and `graph.json`) +and the generated Xcode project are git-ignored — see [`.gitignore`](.gitignore). diff --git a/Tools/CodeGraph/run.sh b/Tools/CodeGraph/run.sh new file mode 100755 index 0000000..65c006d --- /dev/null +++ b/Tools/CodeGraph/run.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Generate the Xcode project, build the app's compiler index, extract the +# type/relationship graph, then build and open the Mac Catalyst viewer. +# +# Usage: +# Tools/CodeGraph/run.sh [extra code-graph-extract flags...] +# +# Examples: +# Tools/CodeGraph/run.sh # full: generate + build + extract + open +# Tools/CodeGraph/run.sh --skip-build # reuse an existing index store +# Tools/CodeGraph/run.sh --scheme Where # index a single scheme +# +# For a live graph that updates as you build, run the extractor in watch mode +# in its own terminal (it blocks): see the message printed at the end. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Tools/CodeGraph +repo="$(cd "$here/../.." && pwd)" # repo root +graph="$here/.codegraph/graph.json" + +# --watch makes the extractor block forever, so the viewer would never open. +# Steer the user to run it standalone instead. +for arg in "$@"; do + if [[ "$arg" == "--watch" ]]; then + echo "run.sh is one-shot; run watch mode on its own:" >&2 + echo " $here/.build/release/code-graph-extract --repo \"$repo\" --watch" >&2 + exit 2 + fi +done + +echo "==> [1/4] building code-graph-extract" +(cd "$here" && swift build -c release --product code-graph-extract) + +echo "==> [2/4] extracting graph (tuist generate + build-for-testing + harvest)" +"$here/.build/release/code-graph-extract" --repo "$repo" --output "$graph" "$@" + +echo "==> [3/4] generating + building the viewer" +(cd "$here/Viewer" && mise exec -- tuist generate --no-open) +derived="$here/Viewer/.build/DerivedData" +xcrun xcodebuild build \ + -workspace "$here/Viewer/CodeGraphViewer.xcworkspace" \ + -scheme CodeGraphViewer \ + -destination 'platform=macOS,variant=Mac Catalyst' \ + -derivedDataPath "$derived" \ + >/dev/null + +app="$derived/Build/Products/Debug-maccatalyst/CodeGraphViewer.app" +echo "==> [4/4] opening $app" +open "$app" + +cat < Date: Wed, 17 Jun 2026 14:13:46 -0400 Subject: [PATCH 13/22] Open the graph by drag-and-drop or launch path graph.json lives in the hidden .codegraph/ directory, which is awkward to reach through an open panel. Add two ways in that route through the existing GraphStore.open (sandbox grant + bookmark + hot-reload watcher): - onOpenURL plus a public.json document type (rank Alternate), so `open -a CodeGraph graph.json` hands the file straight to the app. run.sh now launches the viewer that way instead of expecting a manual pick. - A drag-and-drop target on the window (with a hover highlight) and an empty-state hint. The open panel still works as a fallback. Builds for Mac Catalyst with no warnings. Co-authored-by: Cursor --- Tools/CodeGraph/README.md | 11 ++++++++--- .../Sources/App/CodeGraphViewerApp.swift | 1 + .../Sources/Views/ContentView.swift | 15 +++++++++++++++ .../Sources/Views/EmptyStateView.swift | 14 ++++++++++---- Tools/CodeGraph/Viewer/Project.swift | 12 ++++++++++++ Tools/CodeGraph/run.sh | 12 +++++++----- 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/Tools/CodeGraph/README.md b/Tools/CodeGraph/README.md index 4d556cd..11d4130 100644 --- a/Tools/CodeGraph/README.md +++ b/Tools/CodeGraph/README.md @@ -25,13 +25,18 @@ Tools/CodeGraph/run.sh That builds the extractor, runs `tuist generate` + `xcodebuild build-for-testing` to produce a fresh index, harvests the graph into -`Tools/CodeGraph/.codegraph/graph.json`, then builds and opens the viewer. -Pick the printed `graph.json` from the open panel **once** — the app keeps a -security-scoped bookmark and reopens it automatically next launch. +`Tools/CodeGraph/.codegraph/graph.json`, then builds and **opens the viewer +directly on that file** (via `open -a` — no open panel, since `.codegraph/` is +hidden). The app keeps a security-scoped bookmark and reopens it automatically +next launch. Re-running anything that rewrites `graph.json` (the script, or watch mode below) hot-reloads the open viewer. +To load a graph by hand you can also **drag a `graph.json` onto the window**, or +use the **Open** button (in the open panel, ⌘⇧. toggles hidden files and ⌘⇧G +lets you type a path). + > Requires macOS with Xcode 26+ and the toolchain pinned in [`.mise.toml`](../../.mise.toml) > (Tuist). The viewer is iOS 26 / Mac Catalyst, matching the rest of the repo. diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift index ebaaf99..a626928 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift @@ -9,6 +9,7 @@ struct CodeGraphViewerApp: App { ContentView() .environment(store) .task { store.restore() } + .onOpenURL { store.open($0) } } } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift index b210970..8de4293 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers struct ContentView: View { @Environment(GraphStore.self) private var store @State private var isImporting = false + @State private var isDropTargeted = false var body: some View { NavigationStack { @@ -19,6 +20,20 @@ struct ContentView: View { store.open(url) } } + .dropDestination(for: URL.self) { urls, _ in + guard let url = urls.first(where: \.isFileURL) else { return false } + store.open(url) + return true + } isTargeted: { isDropTargeted = $0 } + .overlay { + if isDropTargeted { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(.tint, style: StrokeStyle(lineWidth: 3, dash: [10, 6])) + .padding(10) + .allowsHitTesting(false) + } + } + .animation(.easeInOut(duration: 0.12), value: isDropTargeted) } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift index 2f9e900..9a4d063 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift @@ -20,11 +20,17 @@ struct EmptyStateView: View { .multilineTextAlignment(.center) .frame(maxWidth: 420) } - Button(action: onOpen) { - Label("Open graph.json", systemImage: "folder") + VStack(spacing: 10) { + Button(action: onOpen) { + Label("Open graph.json", systemImage: "folder") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Text("or drag a graph.json onto the window") + .font(.footnote) + .foregroundStyle(.secondary) } - .buttonStyle(.borderedProminent) - .controlSize(.large) if let error { Label(error, systemImage: "exclamationmark.triangle.fill") diff --git a/Tools/CodeGraph/Viewer/Project.swift b/Tools/CodeGraph/Viewer/Project.swift index 4db584c..b3d072e 100644 --- a/Tools/CodeGraph/Viewer/Project.swift +++ b/Tools/CodeGraph/Viewer/Project.swift @@ -31,6 +31,18 @@ let project = Project( "UILaunchScreen": .dictionary([:]), "CFBundleDisplayName": .string("CodeGraph"), "UIApplicationSupportsIndirectInputEvents": .boolean(true), + // Declare we can view JSON so `open -a CodeGraph graph.json` + // (used by run.sh) hands the file to us via onOpenURL with a + // sandbox grant. Rank Alternate so we don't steal the user's + // default .json association. + "CFBundleDocumentTypes": .array([ + .dictionary([ + "CFBundleTypeName": .string("Code graph"), + "CFBundleTypeRole": .string("Viewer"), + "LSHandlerRank": .string("Alternate"), + "LSItemContentTypes": .array([.string("public.json")]), + ]), + ]), ]), sources: ["CodeGraphViewer/Sources/**"], entitlements: .dictionary([ diff --git a/Tools/CodeGraph/run.sh b/Tools/CodeGraph/run.sh index 65c006d..1941a83 100755 --- a/Tools/CodeGraph/run.sh +++ b/Tools/CodeGraph/run.sh @@ -46,18 +46,20 @@ xcrun xcodebuild build \ >/dev/null app="$derived/Build/Products/Debug-maccatalyst/CodeGraphViewer.app" -echo "==> [4/4] opening $app" -open "$app" +echo "==> [4/4] opening the viewer on $graph" +# Hand the graph straight to the app so nobody has to find it in the hidden +# .codegraph/ directory through an open panel. +open -a "$app" "$graph" cat < Date: Fri, 19 Jun 2026 13:50:22 -0400 Subject: [PATCH 14/22] Add the CodeGraph plan under Tools/CodeGraph/Plans Check in the original "Swift Code Graph Visualizer" plan this tool was built from, for provenance. Reproduced verbatim from the planning step; it predates implementation so some details drifted (e.g. it lists 8 edge kinds; the model ended up with 11). Co-authored-by: Cursor --- .../Plans/swift-code-graph-visualizer.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 Tools/CodeGraph/Plans/swift-code-graph-visualizer.md diff --git a/Tools/CodeGraph/Plans/swift-code-graph-visualizer.md b/Tools/CodeGraph/Plans/swift-code-graph-visualizer.md new file mode 100644 index 0000000..90558c9 --- /dev/null +++ b/Tools/CodeGraph/Plans/swift-code-graph-visualizer.md @@ -0,0 +1,144 @@ +# Swift Code Graph Visualizer ("CodeGraph") + +A self-contained tool under `Tools/CodeGraph/`, decoupled from the iOS Tuist targets. Two processes share one Codable schema: + +- **`code-graph-extract`** — a macOS command-line tool (SwiftPM, links `IndexStoreDB` + `ArgumentParser`). Builds/locates an index store, harvests every symbol and relationship, and writes `graph.json`. Has a `--watch` mode. +- **CodeGraph viewer** — a Mac Catalyst SwiftUI app (its own standalone Tuist project). Watches `graph.json`, runs a force-directed layout, and renders an interactive canvas with filtering, manual drag, and saved views. + +This split exists because `IndexStoreDB` dynamically loads `libIndexStore` from the toolchain and reads a *build artifact* — it belongs in a CLI, not a sandboxed Catalyst app. The viewer never links it; it only reads a user-granted JSON file. + +## Architecture + +```mermaid +flowchart LR + subgraph extract [Extraction: macOS CLI] + build["xcodebuild build-for-testing (iOS Simulator, indexing on)"] + store["Index store: DerivedData/Index.noindex/DataStore"] + cli["code-graph-extract (IndexStoreDB + ArgumentParser)"] + json["graph.json (CodeGraphModel)"] + build --> store --> cli --> json + end + subgraph view [Visualization: Catalyst SwiftUI app] + watch["File watcher (security-scoped bookmark)"] + layout["Layout engine (force-directed actor)"] + canvas["SwiftUI Canvas + node chips: pan/zoom/filter/drag"] + saved["Saved views + pinned positions (keyed by USR)"] + watch --> layout --> canvas --> saved + end + json -->|"watched"| watch +``` + +Why this matters for the choices you made: +- **IndexStore is build-gated**: the `WhereCore`/`WhereUI`/etc. modules are iOS-only (`Package.swift` declares `.iOS(.v26)`; `Project.swift` line 3 `destinations: [.iPhone, .iPad]`), so the index only exists after an iOS-simulator build. "Live" updates therefore land after a build/index pass, not on every keystroke. +- **`--index-store-path` = both**: default to a dedicated reproducible build into `.codegraph/DerivedData`, but accept a custom path to attach to your normal Xcode DerivedData. + +## Repo layout (new, all under `Tools/CodeGraph/`) + +``` +Tools/CodeGraph/ + Package.swift # SwiftPM: CodeGraphModel (lib) + code-graph-extract (exe) + Sources/ + CodeGraphModel/ # shared Codable schema, no deps + code-graph-extract/ # CLI: IndexStoreDB + ArgumentParser + Viewer/ # standalone Tuist project (Catalyst app) + Project.swift # references Package.local(path: "..") -> links CodeGraphModel only + Sources/ + run.sh # build -> extract -> open viewer + README.md + .gitignore # .codegraph/ (DerivedData, graph.json) +``` + +The viewer depends only on `CodeGraphModel`, never on the extractor or `IndexStoreDB`. + +## Shared schema (`CodeGraphModel`) + +Extract everything; filtering is purely a viewer concern (per your "having data and discarding is better than it not existing"). + +```swift +public struct CodeGraph: Codable, Sendable { + public var generatedAt: Date + public var repoPath: String + public var commit: String? + public var modules: [Module] // name, target kind, deps + public var nodes: [Node] + public var edges: [Edge] +} + +public struct Node: Codable, Sendable, Identifiable { + public var id: String // USR (stable across re-extraction) + public var name: String + public var kind: NodeKind // class/struct/enum/protocol/actor/extension/func/var/typealias/associatedtype/module + public var module: String + public var origin: Origin // firstParty / external / test + public var parentID: String? // owning type or module (membership) + public var file: String? + public var line: Int? + public var isGeneric: Bool +} + +public struct Edge: Codable, Sendable, Identifiable { + public var id: String + public var source: String // USR + public var target: String // USR + public var kind: EdgeKind // see mapping below + public var viaMemberID: String? // property/func the reference flowed through + public var count: Int // collapsed duplicate references +} +``` + +## IndexStore -> edge mapping (all 8 kinds) + +`code-graph-extract` opens the DB, enumerates symbols, and reads each symbol's `relations`/occurrence `roles`: + +```swift +import IndexStoreDB +let lib = try IndexStoreLibrary(dylibPath: libIndexStorePath) // `xcrun --find` toolchain libIndexStore.dylib +let db = try IndexStoreDB(storePath: storePath, // .../Index.noindex/DataStore + databasePath: tmpDBPath, library: lib, + waitUntilDoneInitializing: true, listenToUnitEvents: true) +db.pollForUnitChangesAndWait() +``` + +- **inheritance / conformance**: relation `.baseOf`; classify by the base symbol's kind (class base -> `inheritance`, protocol base -> `conformance`). Handles multi-conformance like `SwiftDataStore: WhereStore, EvidenceBlobStore`. +- **override**: relation `.overrideOf`. +- **membership / nesting**: relation `.childOf` (sets `parentID`; drives expand/collapse of members). +- **property / stored-field types**: type occurrence with role `.reference` whose `.containedBy` is a `var`/`let` -> `propertyType` (e.g. `WhereSession.report: YearReport?`). +- **function / initializer signature types**: type references contained by `func`/`init`/`subscript` -> `paramOrReturnType`. +- **construction / DI**: occurrences with role `.call`/`.reference` to an initializer or type contained by a func/var -> `construction` (e.g. assembling `WhereServices`). +- **generics / associated types**: `associatedtype` symbols and their base/reference relations -> `genericConstraint` / `associatedType`. +- **module dependencies**: from the target graph (`Package.swift` + viewer-side parse of cross-module references) -> `moduleDependency`. + +Origin tagging: `firstParty` for symbols under the repo root, `test` for symbols in `*/Tests/**` or `*Tests` modules, `external` for everything else (Apple SDK, ZIPFoundation) emitted as leaf nodes. + +## Index acquisition (default = dedicated build) + +`code-graph-extract` runs (override with `--index-store-path`): + +```bash +xcodebuild build-for-testing \ + -workspace Stuff.xcworkspace -scheme Stuff-Workspace \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -derivedDataPath Tools/CodeGraph/.codegraph/DerivedData \ + COMPILER_INDEX_STORE_ENABLE=YES +``` + +`build-for-testing` on the aggregate scheme compiles libs + app + test bundles, so the "everything" scope (incl. tests) is indexed. Requires a prior `tuist generate --no-open`. + +## Viewer (native SwiftUI, Catalyst) + +- **Load/watch**: open-panel a `graph.json`, keep a security-scoped bookmark, hot-reload on change (DispatchSource/`NSFilePresenter`). No process spawning, sandbox stays on. +- **Layout engine**: a background `actor` running a force-directed simulation (velocity-Verlet springs + repulsion, module-cluster gravity), deterministic seed, settles then idles. Manually dragged nodes become pinned and the sim respects them. (Phase 2: a layered/Sugiyama layout for DAG views like inheritance and module deps.) +- **Render/interact**: SwiftUI `Canvas` draws edges (fast for hundreds of edges); an overlay of positioned node "chips" handles hit-testing, selection, and drag; pan + magnification gestures with a scale transform; inspector popover; expand/collapse a type's members. +- **Filter**: by module, node kind, edge kind, origin (first-party/external/test), name search, and neighborhood focus (N hops from a selection). All client-side over the full graph. +- **Persist**: pinned positions (keyed by USR), active filters, layout choice, and named saved "views" in the app container. On re-extraction, positions for surviving USRs are kept; new nodes auto-place; removed nodes drop. + +## Key risks / decisions baked in + +- **`IndexStoreDB` toolchain match**: pin `indexstore-db` to the branch matching the active Swift toolchain (e.g. `release/6.2`); resolve `libIndexStore` via `xcrun`. +- **Catalyst sandbox**: viewer only reads a user-granted file; the CLI does all privileged work. (App-driven builds were explicitly out — that would require disabling the sandbox.) +- **Biggest effort = the layout engine + canvas**; it is phased so an early version (force-directed + drag + filter) is usable before layered layouts and member expansion land. +- **SwiftSyntax** is intentionally *not* in the MVP (you chose IndexStore); it can later augment exact type spelling/cardinality (e.g. `[WhereStore]?`) if the bare reference edges feel too lossy. + +## Process note + +Per the repo's "Working on plans" convention: branch first, one commit per to-do, and run `./swiftformat --lint` before each commit. The CLI/extractor builds and runs on macOS only. \ No newline at end of file From 9a4d023bfcde639916d436b85a749bca2fbccf8c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 14:19:36 -0400 Subject: [PATCH 15/22] Cut canvas re-render and relayout churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four targeted performance fixes in the viewer's hot paths: - Debounce + warm-start the layout. Filter/search changes now recompute the visible set immediately but coalesce the (previously per-keystroke) relayout, and the engine warm-starts from current positions — keeping existing nodes put and dropping brand-new ones beside their neighbors instead of reshuffling the whole graph from the seed spiral each time. - Cooperative layout cancellation. The force simulation checks Task.isCancelled each iteration, so a superseded relayout bails instead of running to completion on the serialized actor. - Equatable node chips (.equatable()) so a drag or settle animation only re-renders the chips that moved, not every visible node's body. - onChange(of: graph.generatedAt) instead of the whole CodeGraph, dropping a full nodes+edges equality check on every view update. Cold-layout seeding is unchanged (RNG draw order preserved), so the first layout stays deterministic. Builds for Mac Catalyst with no warnings. Co-authored-by: Cursor --- .../Sources/Layout/LayoutEngine.swift | 111 ++++++++++++++++-- .../Sources/ViewModel/GraphViewModel.swift | 44 ++++++- .../Sources/Views/GraphCanvasView.swift | 23 ++-- .../Sources/Views/NodeChipView.swift | 14 ++- 4 files changed, 159 insertions(+), 33 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift index f05e072..d739e34 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift @@ -19,6 +19,10 @@ struct LayoutInput { var edges: [Edge] /// Nodes the user dragged: held fixed but still repel/attract others. var pinned: [String: CGPoint] + /// Current positions to warm-start from. Nodes present here keep their spot + /// (so a filter tweak doesn't reshuffle the whole graph); nodes absent are + /// freshly seeded near their neighbors. Empty for a cold first layout. + var initial: [String: CGPoint] var size: CGSize } @@ -39,7 +43,10 @@ actor LayoutEngine { let centerY = height / 2 // Ideal edge length; floored so dense clusters stay legible. let k = max(36.0, (width * height / Double(count)).squareRoot()) - let steps = iterations ?? adaptiveIterations(for: count) + // A warm start (most nodes already placed) only needs a gentle relax, so + // it runs fewer, cooler iterations and barely disturbs the prior layout. + let warm = !input.initial.isEmpty + let steps = iterations ?? adaptiveIterations(for: count, warm: warm) var index = [String: Int](minimumCapacity: count) for (i, node) in nodes.enumerated() { @@ -48,6 +55,7 @@ actor LayoutEngine { var posX = [Double](repeating: 0, count: count) var posY = [Double](repeating: 0, count: count) + var rng = SplitMix64(seed: 0xD1B5_4A32_D192_ED03) seedPositions( into: &posX, &posY, @@ -56,14 +64,6 @@ actor LayoutEngine { radius: min(centerX, centerY), ) - var pinnedIndices = Set() - for (id, point) in input.pinned { - guard let i = index[id] else { continue } - posX[i] = Double(point.x) - posY[i] = Double(point.y) - pinnedIndices.insert(i) - } - let edges: [(Int, Int, Double)] = input.edges.compactMap { edge in guard let source = index[edge.source], let target = index[edge.target], source != target @@ -73,13 +73,43 @@ actor LayoutEngine { return (source, target, edge.weight) } + // Warm start: keep already-placed nodes where they are, and drop brand-new + // ones in next to their settled neighbors instead of out on the spiral. + var seeded = Set() + for (id, point) in input.initial { + guard let i = index[id] else { continue } + posX[i] = Double(point.x) + posY[i] = Double(point.y) + seeded.insert(i) + } + if warm { + warmSeedNewNodes( + nodes: nodes, + edges: edges, + posX: &posX, + posY: &posY, + seeded: seeded, + rng: &rng, + ) + } + + var pinnedIndices = Set() + for (id, point) in input.pinned { + guard let i = index[id] else { continue } + posX[i] = Double(point.x) + posY[i] = Double(point.y) + pinnedIndices.insert(i) + } + var dispX = [Double](repeating: 0, count: count) var dispY = [Double](repeating: 0, count: count) - var temperature = min(width, height) / 6 + var temperature = min(width, height) / (warm ? 16 : 6) let cooling = pow(2.0 / max(temperature, 2), 1.0 / Double(max(steps, 1))) - var rng = SplitMix64(seed: 0xD1B5_4A32_D192_ED03) for _ in 0 ..< steps { + // The result is discarded if the task was superseded (e.g. another + // filter change); bail early instead of burning the full simulation. + if Task.isCancelled { return [:] } for i in 0 ..< count { dispX[i] = 0 dispY[i] = 0 @@ -221,6 +251,60 @@ actor LayoutEngine { } } + /// Place nodes that have no prior position near their already-placed + /// neighbors (or, failing that, their module's centroid), so newly revealed + /// nodes — e.g. a type's members on expand — appear next to where they + /// belong rather than scattered across the canvas seed spiral. + private func warmSeedNewNodes( + nodes: [LayoutInput.Node], + edges: [(Int, Int, Double)], + posX: inout [Double], + posY: inout [Double], + seeded: Set, + rng: inout SplitMix64, + ) { + let count = nodes.count + guard !seeded.isEmpty, seeded.count < count else { return } + + var sumX = [Double](repeating: 0, count: count) + var sumY = [Double](repeating: 0, count: count) + var degree = [Int](repeating: 0, count: count) + for (source, target, _) in edges { + if !seeded.contains(source), seeded.contains(target) { + sumX[source] += posX[target] + sumY[source] += posY[target] + degree[source] += 1 + } + if !seeded.contains(target), seeded.contains(source) { + sumX[target] += posX[source] + sumY[target] += posY[source] + degree[target] += 1 + } + } + + var moduleSumX = [String: Double]() + var moduleSumY = [String: Double]() + var moduleCount = [String: Int]() + for i in seeded { + let module = nodes[i].module + moduleSumX[module, default: 0] += posX[i] + moduleSumY[module, default: 0] += posY[i] + moduleCount[module, default: 0] += 1 + } + + for i in 0 ..< count where !seeded.contains(i) { + let jitterX = (rng.nextUnit() - 0.5) * 24 + let jitterY = (rng.nextUnit() - 0.5) * 24 + if degree[i] > 0 { + posX[i] = sumX[i] / Double(degree[i]) + jitterX + posY[i] = sumY[i] / Double(degree[i]) + jitterY + } else if let n = moduleCount[nodes[i].module], n > 0 { + posX[i] = moduleSumX[nodes[i].module]! / Double(n) + jitterX + posY[i] = moduleSumY[nodes[i].module]! / Double(n) + jitterY + } + } + } + // MARK: - Seeding & tuning private func seedPositions( @@ -243,8 +327,9 @@ actor LayoutEngine { } } - private func adaptiveIterations(for count: Int) -> Int { - switch count { + private func adaptiveIterations(for count: Int, warm: Bool) -> Int { + if warm { return 140 } + return switch count { case ..<200: 500 case ..<600: 360 case ..<1500: 240 diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index 95bd821..b298e07 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -84,7 +84,14 @@ final class GraphViewModel { ] private(set) var expanded: Set = [] - var selection: String? + var selection: String? { + didSet { recomputeSelectionNeighbors() } + } + + /// Ids one edge away from the current selection, precomputed on selection + /// change so the canvas can dim non-neighbors in O(1) per node instead of + /// rescanning the selection's incident edges for every chip each render. + private(set) var selectionNeighbors: Set = [] /// Drives the inspector presentation off the selection so the selection /// stays the single source of truth (no closure-based bindings in views). @@ -113,6 +120,9 @@ final class GraphViewModel { /// applied (restore / apply saved view). private var isBatching = false private var saveTask: Task? + /// Coalesces the bursty relayouts that filter/search changes would otherwise + /// fire on every keystroke or toggle. + private var relayoutDebounce: Task? init(graph: CodeGraph) { self.graph = graph @@ -179,6 +189,21 @@ final class GraphViewModel { selection = id } + private func recomputeSelectionNeighbors() { + guard let id = selection else { + selectionNeighbors = [] + return + } + var neighbors = Set() + for edge in outgoingByID[id] ?? [] { + neighbors.insert(edge.target) + } + for edge in incomingByID[id] ?? [] { + neighbors.insert(edge.source) + } + selectionNeighbors = neighbors + } + func toggleExpanded(_ id: String) { guard memberCount(id) > 0 else { return } if expanded.contains(id) { @@ -417,17 +442,29 @@ final class GraphViewModel { return visited } - /// Recompute the visible set and re-run the layout. + /// Recompute the visible set immediately (so nodes appear/disappear without + /// lag) but coalesce the expensive relayout so a burst of filter/search + /// changes settles once, not once per keystroke. func invalidate() { guard !isBatching else { return } rebuildVisible() - relayout() + scheduleRelayout() scheduleSave() } // MARK: - Layout + private func scheduleRelayout() { + relayoutDebounce?.cancel() + relayoutDebounce = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(180)) + guard !Task.isCancelled else { return } + self?.relayout() + } + } + func relayout() { + relayoutDebounce?.cancel() layoutTask?.cancel() let input = LayoutInput( nodes: visibleNodes.map { .init(id: $0.id, module: $0.module) }, @@ -439,6 +476,7 @@ final class GraphViewModel { ) }, pinned: pinned.filter { visibleIDs.contains($0.key) }, + initial: positions.filter { visibleIDs.contains($0.key) }, size: canvasSize, ) isLayingOut = true diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift index 0e3dd6d..b07ac38 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -43,7 +43,7 @@ struct GraphCanvasView: View { didFit = true } } - .onChange(of: model.graph) { _, _ in didFit = false } + .onChange(of: model.graph.generatedAt) { _, _ in didFit = false } } } @@ -153,10 +153,8 @@ struct GraphCanvasView: View { private func chip(for node: Node) -> some View { let selected = model.selection == node.id - let dimmed = model.selection != nil && !selected && !isNeighbor( - node.id, - of: model.selection!, - ) + let dimmed = model.selection != nil && !selected && !model.selectionNeighbors + .contains(node.id) return NodeChipView( node: node, isSelected: selected, @@ -166,6 +164,10 @@ struct GraphCanvasView: View { isDimmed: dimmed, onToggleExpand: { model.toggleExpanded(node.id) }, ) + // Skip rebuilding a chip's body (capsule, shadow, labels) when none of + // its inputs changed — so a drag or settle animation only re-renders the + // handful of chips that actually moved, not every visible node. + .equatable() .onTapGesture { model.select(node.id) } .gesture(nodeDrag(node)) .contextMenu { nodeMenu(node) } @@ -227,17 +229,6 @@ struct GraphCanvasView: View { // MARK: - Helpers - private func isNeighbor(_ id: String, of selected: String) -> Bool { - if id == selected { return true } - for edge in model.outgoing(selected) where edge.target == id { - return true - } - for edge in model.incoming(selected) where edge.source == id { - return true - } - return false - } - private func clamp(_ value: CGFloat) -> CGFloat { min(max(value, 0.15), 3.0) } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift index e771f17..7ba29e2 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift @@ -3,7 +3,10 @@ import SwiftUI /// A single node rendered as a rounded "chip": glyph + name, with affordances /// for expanding members, and selection / pin emphasis. -struct NodeChipView: View { +/// +/// `Equatable` (closure excluded — it only ever captures the stable node id) so +/// `.equatable()` can skip re-rendering unchanged chips during drags/animations. +struct NodeChipView: View, Equatable { let node: Node let isSelected: Bool let isPinned: Bool @@ -12,6 +15,15 @@ struct NodeChipView: View { let isDimmed: Bool let onToggleExpand: () -> Void + static func == (lhs: NodeChipView, rhs: NodeChipView) -> Bool { + lhs.node == rhs.node + && lhs.isSelected == rhs.isSelected + && lhs.isPinned == rhs.isPinned + && lhs.memberCount == rhs.memberCount + && lhs.isExpanded == rhs.isExpanded + && lhs.isDimmed == rhs.isDimmed + } + private var accent: Color { GraphStyle.color(for: node.kind) } From 3c581e75b6b43ba18b8fb1fddef9d08b09fd7671 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 14:24:48 -0400 Subject: [PATCH 16/22] Cull off-screen chips and trim chip shadows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Viewport culling: only nodes whose positions land inside the visible canvas rect (grown by a one-viewport margin) are instantiated as chips, so zooming in no longer keeps thousands of off-screen interactive views (each with gestures + a context menu) alive. Culling keys off the committed scale/offset, so an in-flight pan/zoom just transforms the existing layer rather than re-culling every frame; the margin covers a full viewport of panning before any gutter shows, and at fit-zoom the rect spans the whole graph so nothing is dropped. - Drop the per-chip ambient shadow; only the selected chip casts one. A shadow is a blur/compositing pass per chip, which piled up across many visible nodes — the border already separates chips from the edges and background. Builds for Mac Catalyst with no warnings. Co-authored-by: Cursor --- .../Sources/Views/GraphCanvasView.swift | 32 ++++++++++++++++--- .../Sources/Views/NodeChipView.swift | 9 ++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift index b07ac38..d53bd53 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -25,7 +25,7 @@ struct GraphCanvasView: View { GeometryReader { geo in ZStack(alignment: .topLeading) { background - content + content(viewport: geo.size) .scaleEffect(liveScale) .offset(liveOffset) } @@ -56,11 +56,11 @@ struct GraphCanvasView: View { .gesture(panGesture) } - private var content: some View { + private func content(viewport: CGSize) -> some View { ZStack(alignment: .topLeading) { edgeCanvas .allowsHitTesting(false) - nodeLayer + nodeLayer(viewport: viewport) } .frame( width: model.canvasSize.width, @@ -142,8 +142,8 @@ struct GraphCanvasView: View { context.fill(head, with: .color(color)) } - private var nodeLayer: some View { - ForEach(model.visibleNodes) { node in + private func nodeLayer(viewport: CGSize) -> some View { + ForEach(culledNodes(viewport: viewport)) { node in if let point = model.positions[node.id] { chip(for: node) .position(point) @@ -151,6 +151,28 @@ struct GraphCanvasView: View { } } + /// Only the nodes whose positions fall inside the visible canvas rect (grown + /// by a one-viewport margin) get instantiated as chips — so zooming in + /// doesn't keep thousands of off-screen interactive views alive. Keyed to the + /// committed `scale`/`offset` (not the live gesture values), so an in-flight + /// pan/zoom just transforms the existing layer instead of re-culling. + private func culledNodes(viewport: CGSize) -> [Node] { + guard viewport.width > 0, viewport.height > 0, scale > 0 else { + return model.visibleNodes + } + let halfW = model.canvasSize.width / 2 + let halfH = model.canvasSize.height / 2 + let minX = halfW + (-offset.width - halfW) / scale - viewport.width / scale + let maxX = halfW + (viewport.width - offset.width - halfW) / scale + viewport.width / scale + let minY = halfH + (-offset.height - halfH) / scale - viewport.height / scale + let maxY = halfH + (viewport.height - offset.height - halfH) / scale + viewport + .height / scale + return model.visibleNodes.filter { node in + guard let point = model.positions[node.id] else { return false } + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY + } + } + private func chip(for node: Node) -> some View { let selected = model.selection == node.id let dimmed = model.selection != nil && !selected && !model.selectionNeighbors diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift index 7ba29e2..099ad73 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift @@ -70,10 +70,13 @@ struct NodeChipView: View, Equatable { } } .opacity(isDimmed ? 0.28 : (node.origin == .external ? 0.85 : 1)) + // Only the selected chip casts a shadow. A per-chip ambient shadow is a + // blur/compositing pass each, which adds up across many visible nodes; + // the border already separates chips from edges and the background. .shadow( - color: isSelected ? accent.opacity(0.55) : .black.opacity(0.12), - radius: isSelected ? 7 : 2, - y: 1, + color: isSelected ? accent.opacity(0.55) : .clear, + radius: isSelected ? 7 : 0, + y: isSelected ? 1 : 0, ) .fixedSize() } From 1103945c3dbc3de148ad06dd57477b30ef5c3b74 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 14:50:29 -0400 Subject: [PATCH 17/22] Add unit tests for the graph's deterministic logic Cover the pure pieces flagged as untested (review item #8): - SymbolMapping: index-symbol-kind/subkind to NodeKind mapping, synthesized-name filtering, isType, and path-based origin. Added a test target to the extractor package (@testable import of the CLI). - LayoutEngine: same input settles identically, pinned nodes stay put, warm-start preserves seeded positions and anchors new nodes near neighbors, and a cancelled run bails empty. - GraphViewModel: origin/kind/edge/module/name filters, expand-to-reveal members, N-hop focus neighborhood, and persistence reconciliation that drops references to symbols that no longer exist. To make the view-model tests deterministic, ViewerPersistence and GraphViewModel now take an injectable UserDefaults/persistence so each test runs against an isolated suite instead of the app container. Wire a CodeGraphViewerTests unit-test bundle + scheme into the viewer project. Pin the Catalyst MACOSX_DEPLOYMENT_TARGET to 26.0 so the host app and tests launch on a macOS 26 machine (iOS 26 otherwise derives a macOS 27 Catalyst deployment). Co-authored-by: Cursor --- Tools/CodeGraph/Package.swift | 8 + .../SymbolMappingTests.swift | 145 +++++++++++ .../Sources/Model/ViewerState.swift | 7 +- .../Sources/ViewModel/GraphViewModel.swift | 5 +- .../Sources/GraphViewModelTests.swift | 235 ++++++++++++++++++ .../Sources/LayoutEngineTests.swift | 125 ++++++++++ Tools/CodeGraph/Viewer/Project.swift | 26 ++ 7 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift create mode 100644 Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift diff --git a/Tools/CodeGraph/Package.swift b/Tools/CodeGraph/Package.swift index fab53e3..6a55218 100644 --- a/Tools/CodeGraph/Package.swift +++ b/Tools/CodeGraph/Package.swift @@ -26,5 +26,13 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), ], ), + .testTarget( + name: "CodeGraphExtractTests", + dependencies: [ + "code-graph-extract", + .product(name: "CodeGraphModel", package: "CodeGraphModel"), + .product(name: "IndexStoreDB", package: "indexstore-db"), + ], + ), ], ) diff --git a/Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift b/Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift new file mode 100644 index 0000000..7922a29 --- /dev/null +++ b/Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift @@ -0,0 +1,145 @@ +@testable import code_graph_extract +import CodeGraphModel +import IndexStoreDB +import Testing + +/// `SymbolMapping` is the pure translation from index-store symbol metadata to +/// the graph vocabulary, so it can be exercised with hand-built `Symbol`s +/// without opening a real index store. +struct SymbolMappingTests { + private func symbol( + _ name: String, + _ kind: IndexSymbolKind, + subKind: IndexSymbolSubKind = .none, + properties: SymbolProperty = SymbolProperty(), + ) -> Symbol { + Symbol( + usr: "s:\(name)", + name: name, + kind: kind, + subKind: subKind, + properties: properties, + language: .swift, + ) + } + + // MARK: - nodeKind + + @Test func mapsNominalTypeKinds() { + #expect(SymbolMapping.nodeKind(for: symbol("A", .class)) == .class) + #expect(SymbolMapping.nodeKind(for: symbol("A", .struct)) == .struct) + #expect(SymbolMapping.nodeKind(for: symbol("A", .enum)) == .enum) + #expect(SymbolMapping.nodeKind(for: symbol("A", .protocol)) == .protocol) + #expect(SymbolMapping.nodeKind(for: symbol("A", .extension)) == .extension) + // C/C++ unions surface as structs. + #expect(SymbolMapping.nodeKind(for: symbol("A", .union)) == .struct) + } + + @Test func mapsMemberAndFreeKinds() { + #expect(SymbolMapping.nodeKind(for: symbol("f", .function)) == .function) + #expect(SymbolMapping.nodeKind(for: symbol("m", .instanceMethod)) == .method) + #expect(SymbolMapping.nodeKind(for: symbol("init", .constructor)) == .initializer) + #expect(SymbolMapping.nodeKind(for: symbol("p", .instanceProperty)) == .property) + #expect(SymbolMapping.nodeKind(for: symbol("v", .variable)) == .property) + #expect(SymbolMapping.nodeKind(for: symbol("c", .enumConstant)) == .enumCase) + } + + @Test func typealiasSubKindsDisambiguate() { + #expect(SymbolMapping.nodeKind(for: symbol("T", .typealias)) == .typeAlias) + #expect( + SymbolMapping + .nodeKind(for: symbol("T", .typealias, subKind: .swiftAssociatedType)) == + .associatedType, + ) + // A generic type parameter (``) is noise, not a node. + #expect( + SymbolMapping + .nodeKind(for: symbol("T", .typealias, subKind: .swiftGenericTypeParam)) == nil, + ) + } + + @Test func accessorsAreNotNodes() { + #expect( + SymbolMapping + .nodeKind(for: symbol("getter:p", .instanceMethod, subKind: .accessorGetter)) == + nil, + ) + #expect( + SymbolMapping + .nodeKind(for: symbol("setter:p", .instanceProperty, subKind: .accessorSetter)) == + nil, + ) + } + + @Test func nonNodeKindsReturnNil() { + #expect(SymbolMapping.nodeKind(for: symbol("M", .module)) == nil) + #expect(SymbolMapping.nodeKind(for: symbol("x", .parameter)) == nil) + #expect(SymbolMapping.nodeKind(for: symbol("Macro", .macro)) == nil) + } + + @Test func compilerSynthesizedNamesAreFilteredOut() { + // `$`-projected values, property-wrapper/`@Observable` backing storage, + // and anonymous decls never begin a user identifier. + #expect(SymbolMapping.nodeKind(for: symbol("$observation", .variable)) == nil) + #expect(SymbolMapping.nodeKind(for: symbol("_storage", .instanceProperty)) == nil) + #expect(SymbolMapping.nodeKind(for: symbol("", .class)) == nil) + // A normal name with the same kind still maps. + #expect(SymbolMapping.nodeKind(for: symbol("storage", .instanceProperty)) == .property) + } + + // MARK: - isType + + @Test func isTypeCoversNominalTypesOnly() { + for kind in [ + IndexSymbolKind.class, + .struct, + .enum, + .protocol, + .extension, + .typealias, + .union, + ] { + #expect(SymbolMapping.isType(kind), "\(kind) should be a type") + } + for kind in [ + IndexSymbolKind.function, + .variable, + .instanceMethod, + .constructor, + .module, + .enumConstant, + .parameter, + ] { + #expect(!SymbolMapping.isType(kind), "\(kind) should not be a type") + } + } + + // MARK: - origin + + @Test func originClassifiesByPath() { + let repo = "/repo/" + #expect( + SymbolMapping.origin(path: "/usr/lib/x.swift", isSystem: true, repoPrefix: repo) == + .external, + ) + #expect(SymbolMapping.origin(path: "", isSystem: false, repoPrefix: repo) == .external) + #expect( + SymbolMapping.origin(path: "/elsewhere/x.swift", isSystem: false, repoPrefix: repo) == + .external, + ) + #expect( + SymbolMapping + .origin(path: "/repo/Where/Sources/x.swift", isSystem: false, repoPrefix: repo) == + .firstParty, + ) + #expect( + SymbolMapping + .origin( + path: "/repo/Where/Tests/xTests.swift", + isSystem: false, + repoPrefix: repo, + ) == + .test, + ) + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift index 651668a..f7c3ada 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift @@ -36,7 +36,12 @@ struct ViewerRecord: Codable { /// Keyed by repo path so pointing the viewer at different repos keeps their /// arrangements separate. struct ViewerPersistence { - private let defaults = UserDefaults.standard + private let defaults: UserDefaults + + /// Defaults to the app container; tests inject an isolated suite. + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } func load(repoPath: String) -> ViewerRecord { guard diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index b298e07..c00f5e7 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -113,7 +113,7 @@ final class GraphViewModel { private let engine = LayoutEngine() private var layoutTask: Task? - private let persistence = ViewerPersistence() + private let persistence: ViewerPersistence private var record = ViewerRecord() private(set) var savedViews: [SavedView] = [] /// Suppresses relayout/persist side effects while a batch of state is being @@ -124,8 +124,9 @@ final class GraphViewModel { /// fire on every keystroke or toggle. private var relayoutDebounce: Task? - init(graph: CodeGraph) { + init(graph: CodeGraph, persistence: ViewerPersistence = ViewerPersistence()) { self.graph = graph + self.persistence = persistence var byID = [String: Node](minimumCapacity: graph.nodes.count) var children = [String: [Node]]() for node in graph.nodes { diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift new file mode 100644 index 0000000..986497a --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift @@ -0,0 +1,235 @@ +import CodeGraphModel +@testable import CodeGraphViewer +import CoreGraphics +import Foundation +import Testing + +/// Filtering, focus, expansion, and persistence reconciliation are pure +/// functions of the loaded graph plus the current filter state, so they can be +/// exercised against a small hand-built graph with no UI or layout in play. +@MainActor +struct GraphViewModelTests { + // MARK: - Fixture + + /// A deterministic graph spanning all three origins and a member, with both + /// default-visible and opt-in edge kinds: + /// + /// Tester ─construction▶ Alpha ─conformance▶ Proto ◀conformance─ Gamma + /// │ │ + /// propertyType │ └─member▶ field + /// ▼ + /// Beta + /// Alpha ─propertyType▶ ExtType (external) + /// Alpha ─reference▶ Beta (opt-in kind) + /// Beta ─paramOrReturnType▶ Gamma (opt-in kind) + private func makeGraph() -> CodeGraph { + func node( + _ id: String, + _ kind: NodeKind, + _ module: String, + _ origin: Origin, + parent: String?, + ) -> Node { + Node(id: id, name: id, kind: kind, module: module, origin: origin, parentID: parent) + } + func edge( + _ source: String, + _ target: String, + _ kind: EdgeKind, + via: String? = nil, + ) -> Edge { + Edge( + id: "\(source)->\(target):\(kind.rawValue)", + source: source, + target: target, + kind: kind, + viaMemberID: via, + ) + } + let nodes = [ + node("module:App", .module, "App", .firstParty, parent: nil), + node("module:AppTests", .module, "AppTests", .test, parent: nil), + node("module:Ext", .module, "Ext", .external, parent: nil), + node("Alpha", .class, "App", .firstParty, parent: "module:App"), + node("Beta", .struct, "App", .firstParty, parent: "module:App"), + node("Gamma", .enum, "App", .firstParty, parent: "module:App"), + node("Proto", .protocol, "App", .firstParty, parent: "module:App"), + node("field", .property, "App", .firstParty, parent: "Alpha"), + node("ExtType", .class, "Ext", .external, parent: "module:Ext"), + node("Tester", .struct, "AppTests", .test, parent: "module:AppTests"), + ] + let edges = [ + edge("Alpha", "Proto", .conformance), + edge("Alpha", "Beta", .propertyType, via: "field"), + edge("Alpha", "field", .member), + edge("Alpha", "ExtType", .propertyType), + edge("Tester", "Alpha", .construction), + edge("Gamma", "Proto", .conformance), + edge("Alpha", "Beta", .reference), + edge("Beta", "Gamma", .paramOrReturnType), + ] + return CodeGraph( + generatedAt: Date(timeIntervalSince1970: 0), + repoPath: "/test/repo", + modules: [], + nodes: nodes, + edges: edges, + ) + } + + /// A persistence backed by a throwaway, uniquely-named suite so each test + /// starts from empty defaults and never touches the app container. + private func isolatedDefaults() throws -> UserDefaults { + try #require(UserDefaults(suiteName: "codegraph.test.\(UUID())")) + } + + private func makeModel() throws -> GraphViewModel { + let persistence = try ViewerPersistence(defaults: isolatedDefaults()) + return GraphViewModel(graph: makeGraph(), persistence: persistence) + } + + private func visibleIDs(_ model: GraphViewModel) -> Set { + Set(model.visibleNodes.map(\.id)) + } + + private func hasEdge( + _ model: GraphViewModel, + from source: String, + to target: String, + kind: EdgeKind, + ) -> Bool { + model.visibleEdges + .contains { $0.source == source && $0.target == target && $0.kind == kind } + } + + // MARK: - Default filters + + @Test func defaultsShowFirstPartyAndTestTypesOnly() throws { + let model = try makeModel() + // Members (collapsed), external nodes, and module groupings are hidden by + // default; the structural + propertyType edges among visible types show. + #expect(visibleIDs(model) == ["Alpha", "Beta", "Gamma", "Proto", "Tester"]) + #expect(model.visibleEdges.count == 4) + #expect(hasEdge(model, from: "Alpha", to: "Proto", kind: .conformance)) + #expect(hasEdge(model, from: "Tester", to: "Alpha", kind: .construction)) + // The opt-in `.reference` and `.paramOrReturnType` edges are not default. + #expect(!hasEdge(model, from: "Alpha", to: "Beta", kind: .reference)) + } + + // MARK: - Origin filters + + @Test func showExternalRevealsExternalNodesAndEdges() throws { + let model = try makeModel() + model.showExternal = true + #expect(visibleIDs(model).contains("ExtType")) + #expect(hasEdge(model, from: "Alpha", to: "ExtType", kind: .propertyType)) + } + + @Test func hidingTestsRemovesTestNodes() throws { + let model = try makeModel() + model.showTests = false + #expect(visibleIDs(model) == ["Alpha", "Beta", "Gamma", "Proto"]) + #expect(!hasEdge(model, from: "Tester", to: "Alpha", kind: .construction)) + } + + @Test func showModulesRevealsOnlyOriginVisibleModules() throws { + let model = try makeModel() + model.showModules = true + let ids = visibleIDs(model) + #expect(ids.contains("module:App")) + #expect(ids.contains("module:AppTests")) + // The external module stays hidden because showExternal is still off. + #expect(!ids.contains("module:Ext")) + } + + // MARK: - Kind / module / name filters + + @Test func nodeKindFilterHidesProtocols() throws { + let model = try makeModel() + model.toggleNodeKind(.protocol) + #expect(!visibleIDs(model).contains("Proto")) + #expect(!hasEdge(model, from: "Alpha", to: "Proto", kind: .conformance)) + } + + @Test func hidingAModuleHidesItsNodes() throws { + let model = try makeModel() + model.toggleModuleHidden("App") + #expect(visibleIDs(model) == ["Tester"]) + } + + @Test func nameQueryMatchesCaseInsensitively() throws { + let model = try makeModel() + model.nameQuery = "Alpha" + #expect(visibleIDs(model) == ["Alpha"]) + model.nameQuery = "pro" + #expect(visibleIDs(model) == ["Proto"]) + } + + @Test func edgeKindFilterHidesUnselectedKinds() throws { + let model = try makeModel() + model.toggleEdgeKind(.propertyType) + #expect(!hasEdge(model, from: "Alpha", to: "Beta", kind: .propertyType)) + // Nodes are unaffected by an edge-kind toggle. + #expect(visibleIDs(model).contains("Beta")) + } + + // MARK: - Expansion + + @Test func expandingATypeRevealsItsMembers() throws { + let model = try makeModel() + #expect(!visibleIDs(model).contains("field")) + #expect(model.memberCount("Alpha") == 1) + model.toggleExpanded("Alpha") + #expect(visibleIDs(model).contains("field")) + #expect(hasEdge(model, from: "Alpha", to: "field", kind: .member)) + model.toggleExpanded("Alpha") + #expect(!visibleIDs(model).contains("field")) + } + + // MARK: - Focus + + @Test func focusLimitsToNeighborhoodAndDepthExpands() throws { + let model = try makeModel() + model.focus(on: "Alpha") + // Depth 1: Alpha and its direct neighbors over included edges. Gamma is two + // hops away (Gamma → Proto ← Alpha), so it is excluded. + #expect(visibleIDs(model) == ["Alpha", "Proto", "Beta", "Tester"]) + model.focusDepth = 2 + #expect(visibleIDs(model).contains("Gamma")) + model.clearFocus() + #expect(visibleIDs(model) == ["Alpha", "Beta", "Gamma", "Proto", "Tester"]) + } + + // MARK: - Persistence reconciliation + + @Test func reconcileDropsReferencesToVanishedSymbols() throws { + let graph = makeGraph() + let persistence = try ViewerPersistence(defaults: isolatedDefaults()) + var state = ViewerState() + state.pins = ["Alpha": CGPoint(x: 10, y: 20), "Ghost": CGPoint(x: 1, y: 1)] + state.expanded = ["Alpha", "Ghost2"] + state.focusRoot = "Ghost3" + persistence.save(ViewerRecord(current: state), repoPath: graph.repoPath) + + let model = GraphViewModel(graph: graph, persistence: persistence) + #expect(model.pinned == ["Alpha": CGPoint(x: 10, y: 20)]) + #expect(model.isPinned("Alpha")) + #expect(!model.isPinned("Ghost")) + #expect(model.expanded == ["Alpha"]) + // A focus root that no longer exists is cleared, not left blanking the graph. + #expect(model.focusRoot == nil) + } + + @Test func reconcileKeepsValidPersistedState() throws { + let graph = makeGraph() + let persistence = try ViewerPersistence(defaults: isolatedDefaults()) + var state = ViewerState() + state.focusRoot = "Beta" + state.showExternal = true + persistence.save(ViewerRecord(current: state), repoPath: graph.repoPath) + + let model = GraphViewModel(graph: graph, persistence: persistence) + #expect(model.focusRoot == "Beta") + #expect(model.showExternal) + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift new file mode 100644 index 0000000..9b70448 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift @@ -0,0 +1,125 @@ +@testable import CodeGraphViewer +import CoreGraphics +import Foundation +import Testing + +/// The force-directed layout is seeded from a fixed PRNG and runs no +/// concurrency internally, so the same input must always settle the same way. +struct LayoutEngineTests { + private func node(_ id: String, module: String = "M") -> LayoutInput.Node { + .init(id: id, module: module) + } + + private func edge(_ source: String, _ target: String, weight: Double = 1) -> LayoutInput.Edge { + .init(source: source, target: target, weight: weight) + } + + private func sampleInput( + pinned: [String: CGPoint] = [:], + initial: [String: CGPoint] = [:], + ) -> LayoutInput { + LayoutInput( + nodes: [ + node("a", module: "X"), + node("b", module: "X"), + node("c", module: "X"), + node("d", module: "Y"), + node("e", module: "Y"), + node("f", module: "Y"), + ], + edges: [ + edge("a", "b"), + edge("b", "c"), + edge("c", "a"), + edge("d", "e"), + edge("e", "f"), + edge("a", "d", weight: 2), + ], + pinned: pinned, + initial: initial, + size: CGSize(width: 1000, height: 800), + ) + } + + @Test func sameInputSettlesIdentically() async { + let engine = LayoutEngine() + let input = sampleInput() + let first = await engine.layout(input) + let second = await engine.layout(input) + #expect(first == second) + #expect(first.count == 6) + } + + @Test func emptyInputProducesEmptyLayout() async { + let engine = LayoutEngine() + let input = LayoutInput( + nodes: [], + edges: [], + pinned: [:], + initial: [:], + size: CGSize(width: 800, height: 600), + ) + let result = await engine.layout(input) + #expect(result.isEmpty) + } + + @Test func pinnedNodesStayExactlyWhereDropped() async { + let engine = LayoutEngine() + let pin = CGPoint(x: 123, y: 456) + let result = await engine.layout(sampleInput(pinned: ["a": pin])) + #expect(result["a"] == pin) + } + + @Test func warmStartPreservesSeededPositions() async { + let engine = LayoutEngine() + let seededA = CGPoint(x: 500, y: 500) + let seededB = CGPoint(x: 820, y: 300) + // iterations: 0 isolates the seeding/overlay step from the simulation, so + // the assertions don't depend on the relaxation moving anything. + let result = await engine.layout( + sampleInput(initial: ["a": seededA, "b": seededB]), + iterations: 0, + ) + #expect(result["a"] == seededA) + #expect(result["b"] == seededB) + } + + @Test func newNodesAreSeededNearTheirPlacedNeighbors() async throws { + let engine = LayoutEngine() + // `a` is already placed; `b` is brand new and shares an edge with `a`, so + // a warm start should drop it right next to `a` (within the jitter), not + // out on the cold seed spiral. + let placed = CGPoint(x: 640, y: 410) + let input = LayoutInput( + nodes: [node("a"), node("b")], + edges: [edge("a", "b")], + pinned: [:], + initial: ["a": placed], + size: CGSize(width: 1000, height: 800), + ) + let result = await engine.layout(input, iterations: 0) + let b = try #require(result["b"]) + let distance = hypot(b.x - placed.x, b.y - placed.y) + #expect(distance < 30, "new node landed \(distance)pt from its neighbor") + } + + @Test func cancelledLayoutBailsWithEmptyResult() async { + let engine = LayoutEngine() + // A cold layout over many nodes runs the full (expensive) simulation; the + // task is cancelled before it can start, so the loop's cancellation check + // should short-circuit to an empty result. + let nodes = (0 ..< 60).map { node("n\($0)") } + let edges = (0 ..< 59).map { edge("n\($0)", "n\($0 + 1)") } + let input = LayoutInput( + nodes: nodes, + edges: edges, + pinned: [:], + initial: [:], + size: CGSize(width: 1200, height: 900), + ) + let task = Task { await engine.layout(input) } + task.cancel() + let result = await task.value + #expect(result.isEmpty) + } +} diff --git a/Tools/CodeGraph/Viewer/Project.swift b/Tools/CodeGraph/Viewer/Project.swift index b3d072e..9b32bec 100644 --- a/Tools/CodeGraph/Viewer/Project.swift +++ b/Tools/CodeGraph/Viewer/Project.swift @@ -19,6 +19,11 @@ let project = Project( "TARGETED_DEVICE_FAMILY": "2", "MARKETING_VERSION": "1.0", "CURRENT_PROJECT_VERSION": "1", + // The iOS 26 deployment target otherwise derives a Mac Catalyst macOS + // deployment of 27.0, which won't launch (or run unit tests) on a + // macOS 26 host. Pin it down to the matching macOS so the Catalyst app + // and its test bundle run locally. + "MACOSX_DEPLOYMENT_TARGET": "26.0", ]), targets: [ .target( @@ -54,5 +59,26 @@ let project = Project( .package(product: "CodeGraphModel"), ], ), + .target( + name: "CodeGraphViewerTests", + destinations: [.macCatalyst], + product: .unitTests, + bundleId: "com.stuff.codegraph.viewer.tests", + deploymentTargets: .iOS("26.0"), + sources: ["CodeGraphViewerTests/Sources/**"], + dependencies: [ + .target(name: "CodeGraphViewer"), + .package(product: "CodeGraphModel"), + ], + ), + ], + schemes: [ + .scheme( + name: "CodeGraphViewer", + shared: true, + buildAction: .buildAction(targets: ["CodeGraphViewer", "CodeGraphViewerTests"]), + testAction: .targets(["CodeGraphViewerTests"]), + runAction: .runAction(executable: "CodeGraphViewer"), + ), ], ) From 9f91ff2f398c07edb9b04b30d1b8b5173cda372a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 15:07:36 -0400 Subject: [PATCH 18/22] Drain stdout and stderr concurrently in Shell.capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading stdout to EOF before touching stderr deadlocks if a child fills the ~64KB stderr pipe buffer first: the child blocks writing stderr while we block reading stdout. Drain stderr on a background work item so both pipes empty concurrently. (No injection risk — Process takes an args array, not a shell string.) Co-authored-by: Cursor --- .../CodeGraph/Sources/code-graph-extract/Shell.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift b/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift index d3b2b71..3cb06d4 100644 --- a/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift +++ b/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation /// Thin wrappers over `Process` for the few external commands the extractor @@ -22,8 +23,17 @@ enum Shell { process.standardOutput = stdout process.standardError = stderr try process.run() + // Drain stdout and stderr concurrently: reading one pipe to EOF before + // touching the other deadlocks if the child fills the second pipe's + // ~64KB buffer while we're still blocked on the first. (A + // DispatchWorkItem block isn't `@Sendable`, so it can hold the file + // handle / accumulator without tripping strict-concurrency checks.) + let errHandle = stderr.fileHandleForReading + var errData = Data() + let drainStderr = DispatchWorkItem { errData = errHandle.readDataToEndOfFile() } + DispatchQueue.global(qos: .userInitiated).async(execute: drainStderr) let outData = stdout.fileHandleForReading.readDataToEndOfFile() - let errData = stderr.fileHandleForReading.readDataToEndOfFile() + drainStderr.wait() process.waitUntilExit() let output = String(decoding: outData, as: UTF8.self) guard process.terminationStatus == 0 else { From b9b1da15aefc66bed9d6d9866fa43ec94ad3e10c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 15:11:20 -0400 Subject: [PATCH 19/22] Keep the settling flag honest and seed newly-visible nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small viewer-state fixes: - A cancelled *final* layout task left isLayingOut stuck true (the settling spinner never cleared). Tag each layout with a generation so only the newest task applies its result and clears the flag — whether it finished or was cancelled — while superseded tasks bow out. - Newly-visible nodes had no entry in positions until the debounced relayout finished, so they flashed invisible after a filter change or member expand (the canvas only draws nodes it has a position for). Seed a provisional position on reveal — the centroid of already-placed neighbors, falling back to the module centroid — so they paint at once. Skipped on the cold first layout, where the settle places everything. Co-authored-by: Cursor --- .../Sources/ViewModel/GraphViewModel.swift | 80 ++++++++++++++++++- .../Sources/GraphViewModelTests.swift | 47 +++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index c00f5e7..7bea288 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -123,6 +123,10 @@ final class GraphViewModel { /// Coalesces the bursty relayouts that filter/search changes would otherwise /// fire on every keystroke or toggle. private var relayoutDebounce: Task? + /// Bumped per scheduled layout so only the newest task applies its result + /// and owns `isLayingOut` — a superseded (cancelled) task bows out without + /// leaving the settling indicator stuck on. + private var layoutGeneration = 0 init(graph: CodeGraph, persistence: ViewerPersistence = ViewerPersistence()) { self.graph = graph @@ -414,6 +418,67 @@ final class GraphViewModel { visibleNodes = nodes visibleIDs = ids visibleEdges = edges + seedMissingPositions() + } + + /// Give nodes that just became visible a provisional position so they paint + /// immediately instead of vanishing until the debounced relayout finishes + /// (the canvas only draws nodes present in `positions`). Skipped on the cold + /// first layout — there the settling pass places the whole graph at once. + private func seedMissingPositions() { + guard !positions.isEmpty else { return } + let missing = visibleNodes.filter { positions[$0.id] == nil } + guard !missing.isEmpty else { return } + let centroids = moduleCentroids() + let center = CGPoint(x: canvasSize.width / 2, y: canvasSize.height / 2) + for node in missing { + positions[node.id] = neighborCentroid(for: node) + ?? centroids[node.module] + ?? center + } + } + + /// Average position of a node's already-placed neighbors, so an expanded + /// type's members land on top of it and fan out from there. + private func neighborCentroid(for node: Node) -> CGPoint? { + var sum = CGPoint.zero + var count = 0 + for edge in outgoingByID[node.id] ?? [] { + if let point = positions[edge.target] { + sum.x += point.x + sum.y += point.y + count += 1 + } + } + for edge in incomingByID[node.id] ?? [] { + if let point = positions[edge.source] { + sum.x += point.x + sum.y += point.y + count += 1 + } + } + guard count > 0 else { return nil } + return CGPoint(x: sum.x / Double(count), y: sum.y / Double(count)) + } + + /// Centroid of each module's already-placed visible nodes, for seeding a new + /// node that has no placed neighbor to anchor against. + private func moduleCentroids() -> [String: CGPoint] { + var sums = [String: CGPoint]() + var counts = [String: Int]() + for node in visibleNodes { + guard let point = positions[node.id] else { continue } + sums[node.module, default: .zero].x += point.x + sums[node.module, default: .zero].y += point.y + counts[node.module, default: 0] += 1 + } + return sums.reduce(into: [:]) { result, entry in + let count = counts[entry.key] ?? 1 + result[entry.key] = CGPoint( + x: entry.value.x / Double(count), + y: entry.value.y / Double(count), + ) + } } /// Breadth-first set of node ids reachable from `root` within `depth` hops, @@ -481,12 +546,19 @@ final class GraphViewModel { size: canvasSize, ) isLayingOut = true - layoutTask = Task { [engine] in + layoutGeneration &+= 1 + let generation = layoutGeneration + layoutTask = Task { [engine, weak self] in let result = await engine.layout(input) - guard !Task.isCancelled else { return } - withAnimation(.easeInOut(duration: 0.35)) { - positions = result + guard let self, generation == layoutGeneration else { return } + if !Task.isCancelled { + withAnimation(.easeInOut(duration: 0.35)) { + positions = result + } } + // Only the newest layout reaches here; clear the flag whether it + // finished or was cancelled so a cancelled final task can't leave + // the settling indicator stuck on. isLayingOut = false } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift index 986497a..abed1d1 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift @@ -200,6 +200,53 @@ struct GraphViewModelTests { #expect(visibleIDs(model) == ["Alpha", "Beta", "Gamma", "Proto", "Tester"]) } + // MARK: - Layout + seeding + + /// Run a layout and wait for the async settle. The engine runs off the main + /// actor, so the sleeps yield to let it finish (and bound the wait). + private func settle(_ model: GraphViewModel) async throws { + model.relayout() + for _ in 0 ..< 400 { + if !model.isLayingOut, !model.positions.isEmpty { return } + try await Task.sleep(for: .milliseconds(5)) + } + Issue.record("layout never settled") + } + + @Test func settlingFlagClearsAfterLayout() async throws { + let model = try makeModel() + try await settle(model) + #expect(!model.isLayingOut) + #expect(model.positions.count == visibleIDs(model).count) + } + + @Test func rapidRelayoutsStillClearSettlingFlag() async throws { + let model = try makeModel() + // Two back-to-back relayouts: the first task is superseded and must bow + // out without clearing the flag, leaving the newest task to finish. + model.relayout() + model.relayout() + for _ in 0 ..< 400 { + if !model.isLayingOut, !model.positions.isEmpty { break } + try await Task.sleep(for: .milliseconds(5)) + } + #expect(!model.isLayingOut) + } + + @Test func newlyVisibleNodesAreSeededOntoTheirNeighbor() async throws { + let model = try makeModel() + try await settle(model) + // `field` is a hidden member, so the first layout never placed it. + #expect(model.position("field") == nil) + model.toggleExpanded("Alpha") + // Revealing it seeds a provisional position immediately (before the + // debounced relayout), right on its only placed neighbor, Alpha — so it + // paints at once instead of vanishing. + let alpha = try #require(model.position("Alpha")) + let field = try #require(model.position("field")) + #expect(hypot(field.x - alpha.x, field.y - alpha.y) < 0.5) + } + // MARK: - Persistence reconciliation @Test func reconcileDropsReferencesToVanishedSymbols() throws { From a01b0e141fb220fcf5bfeb0e9d9bc727b6c3cd5b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 15:16:49 -0400 Subject: [PATCH 20/22] Isolate node drags so only the dragged chip + its edges re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging used to rewrite the model's positions dict on every gesture tick. Because @Observable tracks at property granularity, each write re-ran the edge Canvas (O(edges)) and rebuilt chip(for:) for every visible node (O(nodes)) — every frame, though one node moved. Split the canvas into independently-observing layers and drive the live drag from view-local state instead of the model: - DraggableChip owns the live offset in @GestureState, so moving it re-renders only that chip; the committed position lands on the model on drag end. - A view-local CanvasDragState carries the dragged id + live point. - StaticEdgeLayer draws every edge except the dragged node's incident ones and reads only the id, so it stays frozen for the whole drag. - DraggedEdgeLayer redraws just the incident edges from the live point, with neighbor endpoints cached at drag start (O(incident), not O(all)). - NodeLayer (and its O(N) culling) never reads the drag state, so it isn't re-evaluated mid-drag. The user still sees live edge-follow; we just repaint a few views per frame instead of the whole graph. The unused model.drag(_:to:) goes away. Co-authored-by: Cursor --- .../Sources/ViewModel/GraphViewModel.swift | 5 - .../Sources/Views/GraphCanvasView.swift | 368 ++++++++++++------ 2 files changed, 248 insertions(+), 125 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index 7bea288..d245504 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -219,11 +219,6 @@ final class GraphViewModel { invalidate() } - /// Live position update while dragging (no relayout). - func drag(_ id: String, to point: CGPoint) { - positions[id] = point - } - /// Finish a drag: pin the node where it landed and re-settle the rest. func endDrag(_ id: String, at point: CGPoint) { positions[id] = point diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift index d53bd53..7e7174c 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -1,8 +1,20 @@ import CodeGraphModel import SwiftUI -/// The interactive graph: a Canvas draws the edges, positioned chips draw the -/// nodes, and gestures pan / zoom / select / drag-to-pin on top. +/// Transient, view-local drag state shared by the node layer (which moves the +/// dragged chip) and the edge overlay (which redraws its incident edges live). +/// Kept off the model so a drag never rewrites `positions` per tick — only the +/// dragged chip and its handful of incident edges re-render, not the whole graph. +@Observable +final class CanvasDragState { + var nodeID: String? + var point: CGPoint = .zero +} + +/// The interactive graph: a static edge `Canvas`, a live overlay for the dragged +/// node's edges, and positioned chips on top, with gestures to pan / zoom / +/// select / drag-to-pin. The layers are split so an in-flight node drag only +/// re-renders the dragged chip and its incident edges — everything else stays put. struct GraphCanvasView: View { @Bindable var model: GraphViewModel @@ -10,8 +22,8 @@ struct GraphCanvasView: View { @State private var offset: CGSize = .zero @GestureState private var gestureZoom: CGFloat = 1 @GestureState private var gesturePan: CGSize = .zero - @State private var dragStart: (id: String, point: CGPoint)? @State private var didFit = false + @State private var drag = CanvasDragState() private var liveScale: CGFloat { scale * gestureZoom @@ -58,9 +70,11 @@ struct GraphCanvasView: View { private func content(viewport: CGSize) -> some View { ZStack(alignment: .topLeading) { - edgeCanvas + StaticEdgeLayer(model: model, drag: drag) .allowsHitTesting(false) - nodeLayer(viewport: viewport) + DraggedEdgeLayer(model: model, drag: drag) + .allowsHitTesting(false) + NodeLayer(model: model, drag: drag, viewport: viewport, scale: scale, offset: offset) } .frame( width: model.canvasSize.width, @@ -69,23 +83,175 @@ struct GraphCanvasView: View { ) } - private var edgeCanvas: some View { + // MARK: - Gestures + + private var zoomGesture: some Gesture { + MagnifyGesture() + .updating($gestureZoom) { value, state, _ in state = value.magnification } + .onEnded { value in scale = clamp(scale * value.magnification) } + } + + private var panGesture: some Gesture { + DragGesture() + .updating($gesturePan) { value, state, _ in state = value.translation } + .onEnded { value in + offset.width += value.translation.width + offset.height += value.translation.height + } + } + + // MARK: - Helpers + + private func clamp(_ value: CGFloat) -> CGFloat { + min(max(value, 0.15), 3.0) + } + + private func fit(in viewport: CGSize) { + let points = Array(model.positions.values) + guard !points.isEmpty else { return } + let xs = points.map(\.x) + let ys = points.map(\.y) + let minX = xs.min()! + let maxX = xs.max()! + let minY = ys.min()! + let maxY = ys.max()! + let pad: CGFloat = 100 + let boxWidth = max(maxX - minX, 1) + pad + let boxHeight = max(maxY - minY, 1) + pad + let fitted = clamp(min(viewport.width / boxWidth, viewport.height / boxHeight)) + let boxCenter = CGPoint(x: (minX + maxX) / 2, y: (minY + maxY) / 2) + let canvasCenter = CGPoint(x: model.canvasSize.width / 2, y: model.canvasSize.height / 2) + scale = fitted + offset = CGSize( + width: viewport.width / 2 - (canvasCenter.x + (boxCenter.x - canvasCenter.x) * fitted), + height: viewport + .height / 2 - (canvasCenter.y + (boxCenter.y - canvasCenter.y) * fitted), + ) + } + + @ViewBuilder + private var settlingIndicator: some View { + if model.isLayingOut { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Settling \(model.visibleNodes.count) nodes…") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.thinMaterial, in: Capsule()) + .padding(.top, 10) + } + } + + private var legend: some View { + Text("\(model.visibleNodes.count) nodes · \(model.visibleEdges.count) edges") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.thinMaterial, in: Capsule()) + .padding(12) + } +} + +// MARK: - Edge layers + +/// Every visible edge except those touching the node being dragged (drawn live +/// by `DraggedEdgeLayer`). Reads `drag.nodeID` but never `drag.point`, so it +/// stays frozen for the whole drag and only redraws at its start and end. +private struct StaticEdgeLayer: View { + let model: GraphViewModel + let drag: CanvasDragState + + var body: some View { + let draggingID = drag.nodeID + let selection = model.selection + let edges = model.visibleEdges + let positions = model.positions + Canvas { context, _ in + for edge in edges where edge.source != draggingID && edge.target != draggingID { + guard let from = positions[edge.source], let to = positions[edge.target] else { + continue + } + let emphasized = selection == nil || edge.source == selection || edge + .target == selection + EdgeRenderer.draw( + edge: edge, + from: from, + to: to, + emphasized: emphasized, + in: context, + ) + } + } + .frame(width: model.canvasSize.width, height: model.canvasSize.height) + } +} + +/// The dragged node's incident edges, redrawn each tick from the live drag +/// point. Neighbor endpoints are captured when the drag starts (they don't +/// move), so per-tick cost is O(incident edges), not O(all edges). +private struct DraggedEdgeLayer: View { + let model: GraphViewModel + let drag: CanvasDragState + @State private var incident: [IncidentEdge] = [] + + var body: some View { + let point = drag.point + let active = drag.nodeID != nil + let selection = model.selection Canvas { context, _ in - let selection = model.selection - for edge in model.visibleEdges { - guard - let from = model.positions[edge.source], - let to = model.positions[edge.target] - else { continue } - let incident = selection == nil || edge.source == selection || edge + guard active else { return } + for item in incident { + let from = item.draggedIsSource ? point : item.neighbor + let to = item.draggedIsSource ? item.neighbor : point + let emphasized = selection == nil || item.edge.source == selection || item.edge .target == selection - draw(edge: edge, from: from, to: to, emphasized: incident, in: context) + EdgeRenderer.draw( + edge: item.edge, + from: from, + to: to, + emphasized: emphasized, + in: context, + ) } } .frame(width: model.canvasSize.width, height: model.canvasSize.height) + .onChange(of: drag.nodeID) { _, id in + incident = id.map(incidentEdges) ?? [] + } + } + + private func incidentEdges(of id: String) -> [IncidentEdge] { + model.visibleEdges.compactMap { edge in + let draggedIsSource: Bool + let neighborID: String + if edge.source == id { + draggedIsSource = true + neighborID = edge.target + } else if edge.target == id { + draggedIsSource = false + neighborID = edge.source + } else { + return nil + } + guard let neighbor = model.positions[neighborID] else { return nil } + return IncidentEdge(edge: edge, neighbor: neighbor, draggedIsSource: draggedIsSource) + } } +} + +/// A visible edge touching the dragged node, with its (fixed) neighbor endpoint +/// captured at drag start so the overlay needn't touch `positions` per tick. +private struct IncidentEdge { + let edge: GraphEdge + let neighbor: CGPoint + let draggedIsSource: Bool +} - private func draw( +private enum EdgeRenderer { + static func draw( edge: GraphEdge, from: CGPoint, to: CGPoint, @@ -107,11 +273,11 @@ struct GraphCanvasView: View { ), ) if emphasized { - drawArrowhead(from: from, to: to, color: base.opacity(0.7), in: context) + arrowhead(from: from, to: to, color: base.opacity(0.7), in: context) } } - private func drawArrowhead( + private static func arrowhead( from: CGPoint, to: CGPoint, color: Color, @@ -141,14 +307,31 @@ struct GraphCanvasView: View { head.closeSubpath() context.fill(head, with: .color(color)) } +} + +// MARK: - Node layer + +/// The interactive chips. Reads `model` (positions, visible set, selection) but +/// never `drag`, so an in-flight drag — which only mutates `drag` — leaves this +/// layer and its O(N) culling untouched; just the dragged chip re-renders. +private struct NodeLayer: View { + let model: GraphViewModel + let drag: CanvasDragState + let viewport: CGSize + let scale: CGFloat + let offset: CGSize - private func nodeLayer(viewport: CGSize) -> some View { - ForEach(culledNodes(viewport: viewport)) { node in - if let point = model.positions[node.id] { - chip(for: node) - .position(point) + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(culledNodes) { node in + DraggableChip(node: node, model: model, drag: drag, scale: scale) } } + .frame( + width: model.canvasSize.width, + height: model.canvasSize.height, + alignment: .topLeading, + ) } /// Only the nodes whose positions fall inside the visible canvas rect (grown @@ -156,9 +339,9 @@ struct GraphCanvasView: View { /// doesn't keep thousands of off-screen interactive views alive. Keyed to the /// committed `scale`/`offset` (not the live gesture values), so an in-flight /// pan/zoom just transforms the existing layer instead of re-culling. - private func culledNodes(viewport: CGSize) -> [Node] { + private var culledNodes: [Node] { guard viewport.width > 0, viewport.height > 0, scale > 0 else { - return model.visibleNodes + return model.visibleNodes.filter { model.positions[$0.id] != nil } } let halfW = model.canvasSize.width / 2 let halfH = model.canvasSize.height / 2 @@ -172,11 +355,23 @@ struct GraphCanvasView: View { return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY } } +} + +/// One node chip. The live drag offset lives in this view's `@GestureState`, so +/// dragging moves only this chip — the parent node layer never re-evaluates. +/// The committed position lands on the model on drag end. +private struct DraggableChip: View { + let node: Node + let model: GraphViewModel + let drag: CanvasDragState + let scale: CGFloat + @GestureState private var translation: CGSize = .zero - private func chip(for node: Node) -> some View { + var body: some View { let selected = model.selection == node.id let dimmed = model.selection != nil && !selected && !model.selectionNeighbors .contains(node.id) + let base = model.positions[node.id] ?? .zero return NodeChipView( node: node, isSelected: selected, @@ -190,117 +385,50 @@ struct GraphCanvasView: View { // its inputs changed — so a drag or settle animation only re-renders the // handful of chips that actually moved, not every visible node. .equatable() + .position(base) + .offset(x: translation.width / scale, y: translation.height / scale) .onTapGesture { model.select(node.id) } - .gesture(nodeDrag(node)) - .contextMenu { nodeMenu(node) } + .gesture(dragGesture(base: base)) + .contextMenu { menu } } - @ViewBuilder - private func nodeMenu(_ node: Node) -> some View { - Button("Focus on \(node.name)", systemImage: "scope") { - model.focus(on: node.id) - } - if node.kind.isType, model.memberCount(node.id) > 0 { - Button(model.isExpanded(node.id) ? "Collapse members" : "Expand members") { - model.toggleExpanded(node.id) - } - } - if model.isPinned(node.id) { - Button("Unpin", systemImage: "pin.slash") { model.unpin(node.id) } - } - } - - private func nodeDrag(_ node: Node) -> some Gesture { + private func dragGesture(base: CGPoint) -> some Gesture { DragGesture(minimumDistance: 3) + .updating($translation) { value, state, _ in state = value.translation } .onChanged { value in - if dragStart?.id != node.id { - dragStart = (node.id, model.positions[node.id] ?? .zero) - } - model.drag(node.id, to: dragged(value)) + // Set the id once (it drives the static/overlay split); push the + // live point every tick for the incident-edge overlay. + if drag.nodeID != node.id { drag.nodeID = node.id } + drag.point = location(of: value, base: base) } .onEnded { value in - model.endDrag(node.id, at: dragged(value)) - dragStart = nil + model.endDrag(node.id, at: location(of: value, base: base)) + drag.nodeID = nil } } - private func dragged(_ value: DragGesture.Value) -> CGPoint { - let start = dragStart?.point ?? .zero - return CGPoint( - x: start.x + value.translation.width / scale, - y: start.y + value.translation.height / scale, - ) - } - - // MARK: - Gestures - - private var zoomGesture: some Gesture { - MagnifyGesture() - .updating($gestureZoom) { value, state, _ in state = value.magnification } - .onEnded { value in scale = clamp(scale * value.magnification) } - } - - private var panGesture: some Gesture { - DragGesture() - .updating($gesturePan) { value, state, _ in state = value.translation } - .onEnded { value in - offset.width += value.translation.width - offset.height += value.translation.height - } - } - - // MARK: - Helpers - - private func clamp(_ value: CGFloat) -> CGFloat { - min(max(value, 0.15), 3.0) - } - - private func fit(in viewport: CGSize) { - let points = Array(model.positions.values) - guard !points.isEmpty else { return } - let xs = points.map(\.x) - let ys = points.map(\.y) - let minX = xs.min()! - let maxX = xs.max()! - let minY = ys.min()! - let maxY = ys.max()! - let pad: CGFloat = 100 - let boxWidth = max(maxX - minX, 1) + pad - let boxHeight = max(maxY - minY, 1) + pad - let fitted = clamp(min(viewport.width / boxWidth, viewport.height / boxHeight)) - let boxCenter = CGPoint(x: (minX + maxX) / 2, y: (minY + maxY) / 2) - let canvasCenter = CGPoint(x: model.canvasSize.width / 2, y: model.canvasSize.height / 2) - scale = fitted - offset = CGSize( - width: viewport.width / 2 - (canvasCenter.x + (boxCenter.x - canvasCenter.x) * fitted), - height: viewport - .height / 2 - (canvasCenter.y + (boxCenter.y - canvasCenter.y) * fitted), + /// Screen translation is in points; divide by the committed scale to convert + /// to the content space `positions` lives in (no pinch happens mid-drag). + private func location(of value: DragGesture.Value, base: CGPoint) -> CGPoint { + CGPoint( + x: base.x + value.translation.width / scale, + y: base.y + value.translation.height / scale, ) } @ViewBuilder - private var settlingIndicator: some View { - if model.isLayingOut { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Settling \(model.visibleNodes.count) nodes…") - .font(.caption) + private var menu: some View { + Button("Focus on \(node.name)", systemImage: "scope") { + model.focus(on: node.id) + } + if node.kind.isType, model.memberCount(node.id) > 0 { + Button(model.isExpanded(node.id) ? "Collapse members" : "Expand members") { + model.toggleExpanded(node.id) } - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(.thinMaterial, in: Capsule()) - .padding(.top, 10) } - } - - private var legend: some View { - Text("\(model.visibleNodes.count) nodes · \(model.visibleEdges.count) edges") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.thinMaterial, in: Capsule()) - .padding(12) + if model.isPinned(node.id) { + Button("Unpin", systemImage: "pin.slash") { model.unpin(node.id) } + } } } From a99ce8683d305b5e4ccdacac38c5a755bfbc5c3b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 17:15:36 -0400 Subject: [PATCH 21/22] Render type members as rows inside the chip (UML box) Members no longer float as their own nodes wired up with .member arrows; each type now draws as a UML-style box with a header and a compartment of member rows (cap 6 + "+N more"/Show less), data-first ordering, and the property type recovered from propertyType edges. To support this the view model: - never surfaces members as standalone visible nodes - projects every canvas edge endpoint to its owning type, dropping .member edges and any edge that collapses onto one type, de-duped per kind - precomputes the flattened member rows once (the graph is fixed) Drops .member/.override from the default and filterable edge kinds (the former is now visual containment), and updates the model tests for the new members-as-rows semantics. Co-authored-by: Cursor --- .../Sources/ViewModel/GraphViewModel.swift | 130 ++++++++++++-- .../Sources/Views/FilterPanel.swift | 2 +- .../Sources/Views/GraphCanvasView.swift | 6 +- .../Sources/Views/NodeChipView.swift | 168 ++++++++++++++---- .../Sources/GraphViewModelTests.swift | 84 +++++++-- 5 files changed, 330 insertions(+), 60 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index d245504..2cc7373 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -8,6 +8,16 @@ import SwiftUI /// in the view layer (which imports SwiftUI) to avoid the ambiguity. typealias GraphEdge = CodeGraphModel.Edge +/// A member of a type, flattened for display as a row inside the owner's chip +/// (so members no longer float as their own nodes). `typeName` is the member's +/// declared type when we can recover it (stored properties, via `propertyType`). +struct MemberRow: Identifiable, Equatable { + let id: String + let name: String + let kind: NodeKind + let typeName: String? +} + /// Drives the canvas: which nodes/edges are visible, where they sit, what's /// selected/expanded, and re-running the layout when any of that changes. @MainActor @@ -19,6 +29,9 @@ final class GraphViewModel { private let childrenByParent: [String: [Node]] private let outgoingByID: [String: [GraphEdge]] private let incomingByID: [String: [GraphEdge]] + /// Members flattened per owning type, precomputed once (the graph is fixed), + /// ordered for display and annotated with the property's type where known. + private let memberRowsByType: [String: [MemberRow]] /// Filters (a fuller filter UI lands in the next step; these defaults give a /// readable first view: first-party types, members collapsed). @@ -59,17 +72,21 @@ final class GraphViewModel { didSet { if focusRoot != nil { invalidate() } } } - /// A readable starting set: the classic "is-a"/ownership edges plus the two - /// most informative data-flow kinds. The rest are opt-in via filters. + /// A readable starting set: the classic "is-a" edges plus the two most + /// informative data-flow kinds. Membership/override are now shown *inside* + /// the chip (so `.member` never reaches the canvas); the rest are opt-in. nonisolated static let defaultEdgeKinds: Set = [ .inheritance, .conformance, .propertyType, .construction, - .member, - .override, ] + /// Edge kinds the filter UI can toggle. `.member` is excluded — containment + /// is drawn as rows in the type's chip, never as an edge on the canvas. + nonisolated static let filterableEdgeKinds: [EdgeKind] = EdgeKind.allCases + .filter { $0 != .member } + /// Primary (non-member, non-module) node kinds the kind filter governs. nonisolated static let filterableKinds: Set = [ .class, @@ -147,6 +164,7 @@ final class GraphViewModel { outgoing[edge.source, default: []].append(edge) incoming[edge.target, default: []].append(edge) } + memberRowsByType = Self.flattenMembers(children: children, outgoing: outgoing, byID: byID) nodesByID = byID childrenByParent = children outgoingByID = outgoing @@ -180,6 +198,62 @@ final class GraphViewModel { childrenByParent[id]?.count ?? 0 } + /// The member rows rendered inside a type's chip (empty for non-types). + func memberRows(_ id: String) -> [MemberRow] { + memberRowsByType[id] ?? [] + } + + /// Flatten each type's members into display rows, ordered data-first + /// (cases/properties before initializers/methods) and annotated with the + /// property type recovered from the owner's `propertyType` edges. + private static func flattenMembers( + children: [String: [Node]], + outgoing: [String: [GraphEdge]], + byID: [String: Node], + ) -> [String: [MemberRow]] { + var result = [String: [MemberRow]](minimumCapacity: children.count) + for (parent, members) in children { + var typeByMember = [String: String]() + for edge in outgoing[parent] ?? [] where edge.kind == .propertyType { + guard let via = edge.viaMemberID, + let name = byID[edge.target]?.name else { continue } + typeByMember[via] = name + } + result[parent] = members + .sorted(by: memberOrder) + .map { MemberRow( + id: $0.id, + name: $0.name, + kind: $0.kind, + typeName: typeByMember[$0.id], + ) } + } + return result + } + + /// Group rank then declaration line then name, so members read like a UML + /// box: stored data up top, behavior below, stable within each group. + private static func memberOrder(_ a: Node, _ b: Node) -> Bool { + let groupA = memberGroupRank(a.kind) + let groupB = memberGroupRank(b.kind) + if groupA != groupB { return groupA < groupB } + let lineA = a.line ?? .max + let lineB = b.line ?? .max + if lineA != lineB { return lineA < lineB } + return a.name < b.name + } + + private static func memberGroupRank(_ kind: NodeKind) -> Int { + switch kind { + case .enumCase: 0 + case .property: 1 + case .initializer: 2 + case .method, .subscript: 3 + case .class, .struct, .enum, .protocol, .actor, .extension, .typeAlias, + .associatedType, .function, .module, .other: 4 + } + } + func isExpanded(_ id: String) -> Bool { expanded.contains(id) } @@ -376,8 +450,8 @@ final class GraphViewModel { return showModules && matchesQuery(node) } if node.kind.isMember { - guard let parent = node.parentID, expanded.contains(parent) else { return false } - return true + // Members render as rows inside their owner's chip, never as nodes. + return false } if Self.filterableKinds.contains(node.kind), !includedNodeKinds.contains(node.kind) { return false @@ -406,16 +480,50 @@ final class GraphViewModel { ids = focusNeighborhood(root: root, depth: focusDepth, allowed: ids) nodes = graph.nodes.filter { ids.contains($0.id) } } - let edges = graph.edges.filter { - includedEdgeKinds.contains($0.kind) && ids.contains($0.source) && ids - .contains($0.target) - } visibleNodes = nodes visibleIDs = ids - visibleEdges = edges + visibleEdges = canvasEdges(visible: ids) seedMissingPositions() } + /// The edges the canvas draws: every edge endpoint projected to its owning + /// type (members live inside chips now), dropping membership edges and any + /// edge that collapses onto a single type, then de-duplicated so a type pair + /// shows one line per kind regardless of how many members route through it. + private func canvasEdges(visible ids: Set) -> [GraphEdge] { + var seen = Set() + var result = [GraphEdge]() + for edge in graph.edges { + guard edge.kind != .member, includedEdgeKinds.contains(edge.kind) else { continue } + let source = owningType(edge.source) + let target = owningType(edge.target) + guard source != target, ids.contains(source), ids.contains(target) else { continue } + guard seen.insert("\(source)|\(target)|\(edge.kind.rawValue)").inserted + else { continue } + if source == edge.source, target == edge.target { + result.append(edge) + } else { + result.append(GraphEdge( + id: "\(source)|\(target)|\(edge.kind.rawValue)", + source: source, + target: target, + kind: edge.kind, + viaMemberID: edge.viaMemberID, + count: edge.count, + )) + } + } + return result + } + + /// The type a node belongs on the canvas as: a member resolves to its + /// enclosing type; everything else is itself. + private func owningType(_ id: String) -> String { + guard let node = nodesByID[id], node.kind.isMember, let parent = node.parentID + else { return id } + return parent + } + /// Give nodes that just became visible a provisional position so they paint /// immediately instead of vanishing until the debounced relayout finishes /// (the canvas only draws nodes present in `positions`). Skipped on the cold diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift index bda9fb8..a806763 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift @@ -37,7 +37,7 @@ struct FilterPanel: View { } } Section("Edge kinds") { - ForEach(EdgeKind.allCases, id: \.self) { kind in + ForEach(GraphViewModel.filterableEdgeKinds, id: \.self) { kind in toggleRow( kind.rawValue, color: GraphStyle.color(for: kind), diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift index 7e7174c..465f9b0 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -376,7 +376,7 @@ private struct DraggableChip: View { node: node, isSelected: selected, isPinned: model.isPinned(node.id), - memberCount: node.kind.isType ? model.memberCount(node.id) : 0, + rows: model.memberRows(node.id), isExpanded: model.isExpanded(node.id), isDimmed: dimmed, onToggleExpand: { model.toggleExpanded(node.id) }, @@ -421,8 +421,8 @@ private struct DraggableChip: View { Button("Focus on \(node.name)", systemImage: "scope") { model.focus(on: node.id) } - if node.kind.isType, model.memberCount(node.id) > 0 { - Button(model.isExpanded(node.id) ? "Collapse members" : "Expand members") { + if node.kind.isType, model.memberCount(node.id) > ChipMetrics.rowCap { + Button(model.isExpanded(node.id) ? "Collapse members" : "Show all members") { model.toggleExpanded(node.id) } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift index 099ad73..86d47fd 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift @@ -1,16 +1,65 @@ import CodeGraphModel import SwiftUI -/// A single node rendered as a rounded "chip": glyph + name, with affordances -/// for expanding members, and selection / pin emphasis. +/// Geometry shared by the chip renderer and the layout engine so the footprint +/// the layout reserves for a node matches what's actually drawn. Members of a +/// type render as rows *inside* its box (UML-style), capped to ``rowCap`` until +/// expanded. +enum ChipMetrics { + static let rowCap = 6 + static let minWidth: CGFloat = 116 + static let maxWidth: CGFloat = 280 + static let headerHeight: CGFloat = 30 + static let rowHeight: CGFloat = 17 + static let bodyPadding: CGFloat = 6 + + /// Number of member rows shown given the expand state. + static func shownRowCount(total: Int, expanded: Bool) -> Int { + expanded ? total : min(total, rowCap) + } + + /// Estimated footprint of a chip: width clamped to the name / widest shown + /// row; height grows with the shown rows plus the "+N more" affordance. + static func size(name: String, rows: [MemberRow], expanded: Bool) -> CGSize { + let headerWidth = textWidth(name, charWidth: 7) + 50 + guard !rows.isEmpty else { + return CGSize(width: clampWidth(headerWidth), height: headerHeight) + } + let shown = shownRowCount(total: rows.count, expanded: expanded) + let rowsWidth = rows.prefix(shown).map(rowWidth).max() ?? 0 + let hasMoreRow = rows.count > rowCap + let width = clampWidth(max(headerWidth, rowsWidth)) + let rowsHeight = CGFloat(shown + (hasMoreRow ? 1 : 0)) * rowHeight + return CGSize(width: width, height: headerHeight + rowsHeight + bodyPadding) + } + + private static func clampWidth(_ width: CGFloat) -> CGFloat { + min(max(width, minWidth), maxWidth) + } + + private static func rowWidth(_ row: MemberRow) -> CGFloat { + let label = row.typeName.map { "\(row.name): \($0)" } ?? row.name + return textWidth(label, charWidth: 6.2) + 32 + } + + /// Cheap monospace-ish estimate; exact text metrics aren't available here and + /// the chip truncates to the clamped width anyway. + private static func textWidth(_ string: String, charWidth: CGFloat) -> CGFloat { + CGFloat(string.count) * charWidth + } +} + +/// A single type rendered as a rounded UML-style box: a header (glyph + name) +/// over a compartment of member rows, with selection / pin emphasis. Types with +/// no members collapse to a compact pill. /// -/// `Equatable` (closure excluded — it only ever captures the stable node id) so +/// `Equatable` (closures excluded — they only capture the stable node id) so /// `.equatable()` can skip re-rendering unchanged chips during drags/animations. struct NodeChipView: View, Equatable { let node: Node let isSelected: Bool let isPinned: Bool - let memberCount: Int + let rows: [MemberRow] let isExpanded: Bool let isDimmed: Bool let onToggleExpand: () -> Void @@ -19,7 +68,7 @@ struct NodeChipView: View, Equatable { lhs.node == rhs.node && lhs.isSelected == rhs.isSelected && lhs.isPinned == rhs.isPinned - && lhs.memberCount == rhs.memberCount + && lhs.rows == rhs.rows && lhs.isExpanded == rhs.isExpanded && lhs.isDimmed == rhs.isDimmed } @@ -28,7 +77,55 @@ struct NodeChipView: View, Equatable { GraphStyle.color(for: node.kind) } + private var hasMembers: Bool { + !rows.isEmpty + } + + private var cornerRadius: CGFloat { + hasMembers ? 11 : ChipMetrics.headerHeight / 2 + } + + private var shownRows: ArraySlice { + rows.prefix(ChipMetrics.shownRowCount(total: rows.count, expanded: isExpanded)) + } + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + if hasMembers { + Rectangle() + .fill(accent.opacity(0.25)) + .frame(height: 1) + memberList + } + } + .frame( + width: ChipMetrics.size(name: node.name, rows: rows, expanded: isExpanded).width, + alignment: .leading, + ) + .background { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill(Color(uiColor: .systemBackground)) + .overlay { + shape.strokeBorder( + accent.opacity(node.origin == .external ? 0.45 : 0.9), + lineWidth: isSelected ? 2.5 : 1.2, + ) + } + } + .opacity(isDimmed ? 0.28 : (node.origin == .external ? 0.85 : 1)) + // Only the selected chip casts a shadow. A per-chip ambient shadow is a + // blur/compositing pass each, which adds up across many visible nodes; + // the border already separates chips from edges and the background. + .shadow( + color: isSelected ? accent.opacity(0.55) : .clear, + radius: isSelected ? 7 : 0, + y: isSelected ? 1 : 0, + ) + } + + private var header: some View { HStack(spacing: 5) { Image(systemName: GraphStyle.symbol(for: node.kind)) .font(.caption2) @@ -46,38 +143,49 @@ struct NodeChipView: View, Equatable { .font(.system(size: 8)) .foregroundStyle(.orange) } - if memberCount > 0 { + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .frame(height: ChipMetrics.headerHeight) + } + + private var memberList: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(shownRows) { row($0) } + if rows.count > ChipMetrics.rowCap { Button(action: onToggleExpand) { - Image(systemName: isExpanded ? "chevron.down.circle.fill" : - "chevron.right.circle") - .font(.caption2) + Text(isExpanded ? "Show less" : "+\(rows.count - ChipMetrics.rowCap) more") + .font(.system(size: 9, weight: .medium)) .foregroundStyle(.secondary) + .frame(height: ChipMetrics.rowHeight) } .buttonStyle(.plain) } } .padding(.horizontal, 9) - .padding(.vertical, 5) - .background { - Capsule(style: .continuous) - .fill(Color(uiColor: .systemBackground)) - .overlay { - Capsule(style: .continuous) - .strokeBorder( - accent.opacity(node.origin == .external ? 0.45 : 0.9), - lineWidth: isSelected ? 2.5 : 1.2, - ) - } + .padding(.vertical, ChipMetrics.bodyPadding / 2) + } + + private func row(_ member: MemberRow) -> some View { + HStack(spacing: 5) { + Image(systemName: GraphStyle.symbol(for: member.kind)) + .font(.system(size: 8)) + .foregroundStyle(GraphStyle.color(for: member.kind)) + .frame(width: 11) + Text(label(for: member)) + .font(.system(size: 10)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .frame(height: ChipMetrics.rowHeight) + } + + private func label(for member: MemberRow) -> String { + if let typeName = member.typeName { + "\(member.name): \(typeName)" + } else { + member.name } - .opacity(isDimmed ? 0.28 : (node.origin == .external ? 0.85 : 1)) - // Only the selected chip casts a shadow. A per-chip ambient shadow is a - // blur/compositing pass each, which adds up across many visible nodes; - // the border already separates chips from edges and the background. - .shadow( - color: isSelected ? accent.opacity(0.55) : .clear, - radius: isSelected ? 7 : 0, - y: isSelected ? 1 : 0, - ) - .fixedSize() } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift index abed1d1..f02e791 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift @@ -106,8 +106,9 @@ struct GraphViewModelTests { @Test func defaultsShowFirstPartyAndTestTypesOnly() throws { let model = try makeModel() - // Members (collapsed), external nodes, and module groupings are hidden by - // default; the structural + propertyType edges among visible types show. + // Members (now rows inside chips), external nodes, and module groupings + // are hidden by default; the structural + propertyType edges among + // visible types show, and the membership edge never reaches the canvas. #expect(visibleIDs(model) == ["Alpha", "Beta", "Gamma", "Proto", "Tester"]) #expect(model.visibleEdges.count == 4) #expect(hasEdge(model, from: "Alpha", to: "Proto", kind: .conformance)) @@ -173,17 +174,70 @@ struct GraphViewModelTests { #expect(visibleIDs(model).contains("Beta")) } - // MARK: - Expansion + // MARK: - Members - @Test func expandingATypeRevealsItsMembers() throws { + @Test func membersRenderAsRowsNotNodes() throws { let model = try makeModel() + // The member never becomes its own node, and its membership edge never + // reaches the canvas — it shows as a row inside Alpha's chip instead. #expect(!visibleIDs(model).contains("field")) - #expect(model.memberCount("Alpha") == 1) - model.toggleExpanded("Alpha") - #expect(visibleIDs(model).contains("field")) - #expect(hasEdge(model, from: "Alpha", to: "field", kind: .member)) - model.toggleExpanded("Alpha") - #expect(!visibleIDs(model).contains("field")) + #expect(!hasEdge(model, from: "Alpha", to: "field", kind: .member)) + let rows = model.memberRows("Alpha") + #expect(rows.map(\.id) == ["field"]) + // The property's declared type is recovered from Alpha's propertyType edge. + #expect(rows.first?.typeName == "Beta") + #expect(model.memberRows("Beta").isEmpty) + } + + @Test func memberRowsAreOrderedDataBeforeBehavior() throws { + func member(_ id: String, _ kind: NodeKind, parent: String, line: Int) -> Node { + Node( + id: id, + name: id, + kind: kind, + module: "App", + origin: .firstParty, + parentID: parent, + line: line, + ) + } + let graph = CodeGraph( + generatedAt: Date(timeIntervalSince1970: 0), + repoPath: "/test/repo-members", + modules: [], + nodes: [ + Node( + id: "module:App", + name: "App", + kind: .module, + module: "App", + origin: .firstParty, + ), + Node( + id: "Box", + name: "Box", + kind: .class, + module: "App", + origin: .firstParty, + parentID: "module:App", + line: 1, + ), + member("run", .method, parent: "Box", line: 3), + member("count", .property, parent: "Box", line: 1), + member("name", .property, parent: "Box", line: 2), + ], + edges: [], + ) + let persistence = try ViewerPersistence(defaults: isolatedDefaults()) + let model = GraphViewModel(graph: graph, persistence: persistence) + // Stored data (properties, by declaration line) precede behavior (methods). + #expect(model.memberRows("Box").map(\.id) == ["count", "name", "run"]) + } + + @Test func chipCapsRowsUntilExpanded() { + #expect(ChipMetrics.shownRowCount(total: 10, expanded: false) == ChipMetrics.rowCap) + #expect(ChipMetrics.shownRowCount(total: 10, expanded: true) == 10) + #expect(ChipMetrics.shownRowCount(total: 3, expanded: false) == 3) } // MARK: - Focus @@ -236,15 +290,15 @@ struct GraphViewModelTests { @Test func newlyVisibleNodesAreSeededOntoTheirNeighbor() async throws { let model = try makeModel() try await settle(model) - // `field` is a hidden member, so the first layout never placed it. - #expect(model.position("field") == nil) - model.toggleExpanded("Alpha") + // ExtType is hidden by default, so the first layout never placed it. + #expect(model.position("ExtType") == nil) + model.showExternal = true // Revealing it seeds a provisional position immediately (before the // debounced relayout), right on its only placed neighbor, Alpha — so it // paints at once instead of vanishing. let alpha = try #require(model.position("Alpha")) - let field = try #require(model.position("field")) - #expect(hypot(field.x - alpha.x, field.y - alpha.y) < 0.5) + let ext = try #require(model.position("ExtType")) + #expect(hypot(ext.x - alpha.x, ext.y - alpha.y) < 0.5) } // MARK: - Persistence reconciliation From c7e4244949427f55f8bb475171f129023dc02176 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 19 Jun 2026 18:45:04 -0400 Subject: [PATCH 22/22] Space chips by their box size so member-row boxes don't overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member-row chips are tall UML boxes, but the force layout still treated every node as an equal-radius point, so big chips piled on top of each other. Carry each node's drawn size into the layout and compute repulsion over the gap between chip *borders* (the box support function `halfW·|ux| + halfH·|uy|`) rather than center distance, so whole boxes keep clear with only a modest gap instead of overlapping. This is just the box-aware spacing piece of the reverted module-grouping work — the gentle module-centroid gravity and centered seeding from the baseline are untouched (no anchors, regions, or packed boxes). Adds a largeBoxesDoNotOverlap regression test. Co-authored-by: Cursor --- .../Sources/Layout/LayoutEngine.swift | 49 +++++++++++++++++-- .../Sources/ViewModel/GraphViewModel.swift | 11 ++++- .../Sources/LayoutEngineTests.swift | 30 ++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift index d739e34..ac627fd 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift @@ -7,6 +7,9 @@ struct LayoutInput { struct Node { var id: String var module: String + /// The chip's drawn footprint, so repulsion can keep whole boxes — not + /// just their centers — from overlapping. Defaults to a compact pill. + var size: CGSize = .init(width: 120, height: 30) } struct Edge { @@ -53,6 +56,19 @@ actor LayoutEngine { index[node.id] = i } + // Half-extents per node so repulsion keeps whole boxes apart (the support + // function below turns these into a border-to-border gap), and a cell + // size large enough that any overlapping pair lands in adjacent buckets. + var halfW = [Double](repeating: 0, count: count) + var halfH = [Double](repeating: 0, count: count) + var maxBoxDimension = 0.0 + for (i, node) in nodes.enumerated() { + halfW[i] = Double(node.size.width) / 2 + halfH[i] = Double(node.size.height) / 2 + maxBoxDimension = max(maxBoxDimension, Double(max(node.size.width, node.size.height))) + } + let cellSize = max(k, maxBoxDimension) + var posX = [Double](repeating: 0, count: count) var posY = [Double](repeating: 0, count: count) var rng = SplitMix64(seed: 0xD1B5_4A32_D192_ED03) @@ -114,7 +130,17 @@ actor LayoutEngine { dispX[i] = 0 dispY[i] = 0 } - applyRepulsion(posX: posX, posY: posY, dispX: &dispX, dispY: &dispY, k: k, rng: &rng) + applyRepulsion( + posX: posX, + posY: posY, + halfW: halfW, + halfH: halfH, + cellSize: cellSize, + dispX: &dispX, + dispY: &dispY, + k: k, + rng: &rng, + ) applyAttraction( edges: edges, posX: posX, @@ -147,16 +173,24 @@ actor LayoutEngine { /// Grid-bucketed repulsion: each node is pushed only by others in its own /// and adjacent cells, which keeps the cost ~O(n) for spread-out graphs /// while distant pairs contribute negligibly anyway. + /// + /// The force is computed over the gap between the two chips' *borders*, not + /// their centers: the box support function (`halfW·|ux| + halfH·|uy|`) gives + /// the distance from a center to its border along the connecting direction, + /// so subtracting both leaves the border-to-border gap. Big UML boxes then + /// keep their distance instead of overlapping the way equal-radius points do. private func applyRepulsion( posX: [Double], posY: [Double], + halfW: [Double], + halfH: [Double], + cellSize: Double, dispX: inout [Double], dispY: inout [Double], k: Double, rng: inout SplitMix64, ) { let count = posX.count - let cellSize = k var grid = [Cell: [Int]]() for i in 0 ..< count { grid[Cell(posX[i], posY[i], cellSize), default: []].append(i) @@ -177,9 +211,14 @@ actor LayoutEngine { dist2 = dx * dx + dy * dy + 0.01 } let dist = dist2.squareRoot() - let force = k2 / dist - dispX[i] += dx / dist * force - dispY[i] += dy / dist * force + let ux = dx / dist + let uy = dy / dist + let border = halfW[i] * abs(ux) + halfH[i] * abs(uy) + + halfW[j] * abs(ux) + halfH[j] * abs(uy) + let gap = max(dist - border, 1) + let force = k2 / gap + dispX[i] += ux * force + dispY[i] += uy * force } } } diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift index 2cc7373..e282668 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -203,6 +203,15 @@ final class GraphViewModel { memberRowsByType[id] ?? [] } + /// The chip's drawn footprint, so the layout reserves the right space and + /// keeps whole boxes — not just their centers — from overlapping. + func chipSize(_ id: String) -> CGSize { + guard let node = nodesByID[id] else { + return CGSize(width: ChipMetrics.minWidth, height: ChipMetrics.headerHeight) + } + return ChipMetrics.size(name: node.name, rows: memberRows(id), expanded: isExpanded(id)) + } + /// Flatten each type's members into display rows, ordered data-first /// (cases/properties before initializers/methods) and annotated with the /// property type recovered from the owner's `propertyType` edges. @@ -636,7 +645,7 @@ final class GraphViewModel { relayoutDebounce?.cancel() layoutTask?.cancel() let input = LayoutInput( - nodes: visibleNodes.map { .init(id: $0.id, module: $0.module) }, + nodes: visibleNodes.map { .init(id: $0.id, module: $0.module, size: chipSize($0.id)) }, edges: visibleEdges.map { .init( source: $0.source, diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift index 9b70448..7a70bf4 100644 --- a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift @@ -50,6 +50,36 @@ struct LayoutEngineTests { #expect(first.count == 6) } + @Test func largeBoxesDoNotOverlap() async throws { + let engine = LayoutEngine() + // Tall member-row chips must not sit on top of each other: repulsion + // measures the gap between box borders, not just center distance. + let box = CGSize(width: 240, height: 160) + let input = LayoutInput( + nodes: [ + .init(id: "a", module: "M", size: box), + .init(id: "b", module: "M", size: box), + .init(id: "c", module: "M", size: box), + ], + edges: [], + pinned: [:], + initial: [:], + size: CGSize(width: 1200, height: 1000), + ) + let result = await engine.layout(input) + let ids = ["a", "b", "c"] + for i in 0 ..< ids.count { + for j in (i + 1) ..< ids.count { + let p = try #require(result[ids[i]]) + let q = try #require(result[ids[j]]) + // Rectangles overlap only if they penetrate on *both* axes. + let penetrateX = box.width - abs(p.x - q.x) + let penetrateY = box.height - abs(p.y - q.y) + #expect(penetrateX <= 0 || penetrateY <= 0, "boxes \(ids[i]) and \(ids[j]) overlap") + } + } + } + @Test func emptyInputProducesEmptyLayout() async { let engine = LayoutEngine() let input = LayoutInput(