diff --git a/Tools/CodeGraph/.gitignore b/Tools/CodeGraph/.gitignore new file mode 100644 index 00000000..4a3b9ab4 --- /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/CodeGraphModel/Package.swift b/Tools/CodeGraph/CodeGraphModel/Package.swift new file mode 100644 index 00000000..1bd40de9 --- /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/CodeGraphModel/Sources/CodeGraphModel/CodeGraph.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraph.swift new file mode 100644 index 00000000..d928e1c2 --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Sources/CodeGraphModel/CodeGraphCoding.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/CodeGraphCoding.swift new file mode 100644 index 00000000..eb237b10 --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Sources/CodeGraphModel/Edge.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Edge.swift new file mode 100644 index 00000000..e4693466 --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Sources/CodeGraphModel/Module.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Module.swift new file mode 100644 index 00000000..04964ba4 --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Sources/CodeGraphModel/Node.swift b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Node.swift new file mode 100644 index 00000000..83210bbf --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Sources/CodeGraphModel/Resources/sample-graph.json b/Tools/CodeGraph/CodeGraphModel/Sources/CodeGraphModel/Resources/sample-graph.json new file mode 100644 index 00000000..cdc4f789 --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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/CodeGraphModel/Tests/CodeGraphModelTests/CodeGraphModelTests.swift b/Tools/CodeGraph/CodeGraphModel/Tests/CodeGraphModelTests/CodeGraphModelTests.swift new file mode 100644 index 00000000..5d5b212b --- /dev/null +++ b/Tools/CodeGraph/CodeGraphModel/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) + } +} diff --git a/Tools/CodeGraph/Package.resolved b/Tools/CodeGraph/Package.resolved new file mode 100644 index 00000000..fb2211ac --- /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 new file mode 100644 index 00000000..6a55218b --- /dev/null +++ b/Tools/CodeGraph/Package.swift @@ -0,0 +1,38 @@ +// 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), + ], + products: [ + .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: [ + .executableTarget( + name: "code-graph-extract", + dependencies: [ + .product(name: "CodeGraphModel", package: "CodeGraphModel"), + .product(name: "IndexStoreDB", package: "indexstore-db"), + .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/Plans/swift-code-graph-visualizer.md b/Tools/CodeGraph/Plans/swift-code-graph-visualizer.md new file mode 100644 index 00000000..90558c97 --- /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 diff --git a/Tools/CodeGraph/README.md b/Tools/CodeGraph/README.md new file mode 100644 index 00000000..11d41300 --- /dev/null +++ b/Tools/CodeGraph/README.md @@ -0,0 +1,137 @@ +# 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 +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. + +## 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/Sources/code-graph-extract/CodeGraphExtract.swift b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift new file mode 100644 index 00000000..604022d9 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/CodeGraphExtract.swift @@ -0,0 +1,137 @@ +import ArgumentParser +import CodeGraphModel +import Foundation + +@main +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.", + ) + + @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 + + @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) + let outputPath = output ?? (repoPath + "/Tools/CodeGraph/.codegraph/graph.json") + + 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. + 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 { + 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/DataStoreWatcher.swift b/Tools/CodeGraph/Sources/code-graph-extract/DataStoreWatcher.swift new file mode 100644 index 00000000..51ee6cd2 --- /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) + } +} 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 00000000..2b6f1340 --- /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/GraphBuilder.swift b/Tools/CodeGraph/Sources/code-graph-extract/GraphBuilder.swift new file mode 100644 index 00000000..5e1f8f59 --- /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/IndexStoreInput.swift b/Tools/CodeGraph/Sources/code-graph-extract/IndexStoreInput.swift new file mode 100644 index 00000000..fbfc1cfd --- /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 00000000..76aaa643 --- /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 00000000..3cb06d45 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/Shell.swift @@ -0,0 +1,72 @@ +import Dispatch +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() + // 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() + drainStderr.wait() + 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/SymbolMapping.swift b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift new file mode 100644 index 00000000..27a72246 --- /dev/null +++ b/Tools/CodeGraph/Sources/code-graph-extract/SymbolMapping.swift @@ -0,0 +1,94 @@ +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? { + 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 + // 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 + } + + /// 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, + .swiftAccessorAddressor, .swiftAccessorMutableAddressor: + true + case .none, .cxxCopyConstructor, .cxxMoveConstructor, .swiftExtensionOfStruct, + .swiftExtensionOfClass, .swiftExtensionOfEnum, .swiftExtensionOfProtocol, + .swiftPrefixOperator, .swiftPostfixOperator, .swiftInfixOperator, .swiftSubscript, + .swiftAssociatedType, .swiftGenericTypeParam: + false + } + } +} 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 00000000..f8674cd2 --- /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 + } +} diff --git a/Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift b/Tools/CodeGraph/Tests/CodeGraphExtractTests/SymbolMappingTests.swift new file mode 100644 index 00000000..7922a293 --- /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/App/CodeGraphViewerApp.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift new file mode 100644 index 00000000..a6269287 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/App/CodeGraphViewerApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct CodeGraphViewerApp: App { + @State private var store = GraphStore() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(store) + .task { store.restore() } + .onOpenURL { store.open($0) } + } + } +} 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 00000000..ac627fd9 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Layout/LayoutEngine.swift @@ -0,0 +1,427 @@ +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 + /// 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 { + 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] + /// 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 +} + +/// 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()) + // 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() { + 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) + seedPositions( + into: &posX, + &posY, + centerX: centerX, + centerY: centerY, + radius: min(centerX, centerY), + ) + + 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) + } + + // 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) / (warm ? 16 : 6) + let cooling = pow(2.0 / max(temperature, 2), 1.0 / Double(max(steps, 1))) + + 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 + } + applyRepulsion( + posX: posX, + posY: posY, + halfW: halfW, + halfH: halfH, + cellSize: cellSize, + 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. + /// + /// 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 + 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 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 + } + } + } + } + + 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 + } + } + + /// 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( + 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, warm: Bool) -> Int { + if warm { return 140 } + return 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) + } +} 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 00000000..cbb1824b --- /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 00000000..d33a3019 --- /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 00000000..bde27bc3 --- /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/Model/ViewerState.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift new file mode 100644 index 00000000..f7c3ada6 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Model/ViewerState.swift @@ -0,0 +1,64 @@ +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 + + /// Defaults to the app container; tests inject an isolated suite. + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + 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 new file mode 100644 index 00000000..e2826689 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/ViewModel/GraphViewModel.swift @@ -0,0 +1,677 @@ +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 + +/// 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 +@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]] + /// 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). + var showExternal = false { + didSet { invalidate() } + } + + var showModules = false { + didSet { invalidate() } + } + + var showTests = true { + didSet { invalidate() } + } + + var includedEdgeKinds: Set = GraphViewModel + .defaultEdgeKinds + { + 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" 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, + ] + + /// 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, + .struct, + .enum, + .protocol, + .actor, + .extension, + .typeAlias, + .associatedType, + .function, + ] + + private(set) var expanded: Set = [] + 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). + 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 allModuleNames: [String] + let canvasSize = CGSize(width: 2800, height: 2000) + 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? + /// 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 + self.persistence = persistence + 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) + } + memberRowsByType = Self.flattenMembers(children: children, outgoing: outgoing, byID: byID) + nodesByID = byID + childrenByParent = children + outgoingByID = outgoing + incomingByID = incoming + allModuleNames = Set(graph.nodes.map(\.module)).sorted() + record = persistence.load(repoPath: graph.repoPath) + savedViews = record.savedViews + apply(record.current) + 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 + } + + /// The member rows rendered inside a type's chip (empty for non-types). + func memberRows(_ id: String) -> [MemberRow] { + 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. + 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) + } + + func isPinned(_ id: String) -> Bool { + pinned[id] != nil + } + + // MARK: - Interaction + + func select(_ id: String?) { + 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) { + expanded.remove(id) + } else { + expanded.insert(id) + } + invalidate() + } + + /// 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() + 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 + + 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 + + private func passesFilters(_ node: Node) -> Bool { + guard !hiddenModules.contains(node.module), matchesOrigin(node) else { return false } + if node.kind == .module { + return showModules && matchesQuery(node) + } + if node.kind.isMember { + // 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 + } + return matchesQuery(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 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) } + } + visibleNodes = nodes + visibleIDs = ids + 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 + /// 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, + /// 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 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() + 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, size: chipSize($0.id)) }, + 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) }, + initial: positions.filter { visibleIDs.contains($0.key) }, + size: canvasSize, + ) + isLayingOut = true + layoutGeneration &+= 1 + let generation = layoutGeneration + layoutTask = Task { [engine, weak self] in + let result = await engine.layout(input) + 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/CodeGraphViewer/Sources/Views/ContentView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift new file mode 100644 index 00000000..8de42938 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/ContentView.swift @@ -0,0 +1,159 @@ +import CodeGraphModel +import SwiftUI +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 { + content + .navigationTitle(store.sourceURL?.lastPathComponent ?? "CodeGraph") + .toolbar { toolbar } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.json], + ) { result in + if case let .success(url) = result { + 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) + } + } + + @ViewBuilder + private var content: some View { + if let graph = store.graph { + GraphContainer(graph: graph) + .id(graph.generatedAt) + } 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") + } + } + } + } +} + +/// 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 + @State private var showingFilters = false + @State private var showingSaveView = false + @State private var newViewName = "" + + 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) + } + .toolbar { toolbar } + .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() } + } + + @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) { + viewsMenu + } + ToolbarItem(placement: .primaryAction) { + Button { + showingFilters = true + } label: { + Label("Filters", systemImage: "line.3.horizontal.decrease.circle") + } + } + } + + 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") + } + } +} 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 00000000..9a4d063f --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/EmptyStateView.swift @@ -0,0 +1,47 @@ +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) + } + 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) + } + + 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/FilterPanel.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/FilterPanel.swift new file mode 100644 index 00000000..a8067637 --- /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(GraphViewModel.filterableEdgeKinds, 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 new file mode 100644 index 00000000..465f9b03 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/GraphCanvasView.swift @@ -0,0 +1,457 @@ +import CodeGraphModel +import SwiftUI + +/// 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 + + @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 didFit = false + @State private var drag = CanvasDragState() + + 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(viewport: geo.size) + .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.generatedAt) { _, _ in didFit = false } + } + } + + private var background: some View { + Rectangle() + .fill(Color(uiColor: .systemBackground)) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { model.select(nil) } + .gesture(panGesture) + } + + private func content(viewport: CGSize) -> some View { + ZStack(alignment: .topLeading) { + StaticEdgeLayer(model: model, drag: drag) + .allowsHitTesting(false) + DraggedEdgeLayer(model: model, drag: drag) + .allowsHitTesting(false) + NodeLayer(model: model, drag: drag, viewport: viewport, scale: scale, offset: offset) + } + .frame( + width: model.canvasSize.width, + height: model.canvasSize.height, + alignment: .topLeading, + ) + } + + // 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 + 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 + 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 enum EdgeRenderer { + static 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 { + arrowhead(from: from, to: to, color: base.opacity(0.7), in: context) + } + } + + private static func arrowhead( + 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)) + } +} + +// 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 + + 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 + /// 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 var culledNodes: [Node] { + guard viewport.width > 0, viewport.height > 0, scale > 0 else { + return model.visibleNodes.filter { model.positions[$0.id] != nil } + } + 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 + } + } +} + +/// 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 + + 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, + isPinned: model.isPinned(node.id), + rows: model.memberRows(node.id), + isExpanded: model.isExpanded(node.id), + 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() + .position(base) + .offset(x: translation.width / scale, y: translation.height / scale) + .onTapGesture { model.select(node.id) } + .gesture(dragGesture(base: base)) + .contextMenu { menu } + } + + private func dragGesture(base: CGPoint) -> some Gesture { + DragGesture(minimumDistance: 3) + .updating($translation) { value, state, _ in state = value.translation } + .onChanged { value in + // 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: location(of: value, base: base)) + drag.nodeID = nil + } + } + + /// 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 menu: some View { + Button("Focus on \(node.name)", systemImage: "scope") { + model.focus(on: node.id) + } + if node.kind.isType, model.memberCount(node.id) > ChipMetrics.rowCap { + Button(model.isExpanded(node.id) ? "Collapse members" : "Show all members") { + model.toggleExpanded(node.id) + } + } + if model.isPinned(node.id) { + Button("Unpin", systemImage: "pin.slash") { model.unpin(node.id) } + } + } +} + +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 00000000..bd4c65b5 --- /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/InspectorView.swift b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift new file mode 100644 index 00000000..28acf520 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/InspectorView.swift @@ -0,0 +1,115 @@ +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) + } + } + Button("Focus neighborhood", systemImage: "scope") { + model.focus(on: node.id) + } + } + } + + @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 00000000..86d47fda --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewer/Sources/Views/NodeChipView.swift @@ -0,0 +1,191 @@ +import CodeGraphModel +import SwiftUI + +/// 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` (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 rows: [MemberRow] + let isExpanded: Bool + 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.rows == rhs.rows + && lhs.isExpanded == rhs.isExpanded + && lhs.isDimmed == rhs.isDimmed + } + + private var accent: Color { + 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) + .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) + } + 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) { + 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, 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 + } + } +} diff --git a/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift new file mode 100644 index 00000000..f02e7912 --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/GraphViewModelTests.swift @@ -0,0 +1,336 @@ +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 (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)) + #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: - Members + + @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(!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 + + @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: - 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) + // 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 ext = try #require(model.position("ExtType")) + #expect(hypot(ext.x - alpha.x, ext.y - alpha.y) < 0.5) + } + + // 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 00000000..7a70bf4b --- /dev/null +++ b/Tools/CodeGraph/Viewer/CodeGraphViewerTests/Sources/LayoutEngineTests.swift @@ -0,0 +1,155 @@ +@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 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( + 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 new file mode 100644 index 00000000..9b32bec0 --- /dev/null +++ b/Tools/CodeGraph/Viewer/Project.swift @@ -0,0 +1,84 @@ +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", + // 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( + 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), + // 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([ + "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"), + ], + ), + .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"), + ), + ], +) diff --git a/Tools/CodeGraph/Viewer/Tuist.swift b/Tools/CodeGraph/Viewer/Tuist.swift new file mode 100644 index 00000000..ff8e64fa --- /dev/null +++ b/Tools/CodeGraph/Viewer/Tuist.swift @@ -0,0 +1,3 @@ +import ProjectDescription + +let config = Config() diff --git a/Tools/CodeGraph/run.sh b/Tools/CodeGraph/run.sh new file mode 100755 index 00000000..1941a83d --- /dev/null +++ b/Tools/CodeGraph/run.sh @@ -0,0 +1,65 @@ +#!/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 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 <