Files
CodeMapper/Sources/CodeMapper/ShapeSnapshot.swift
T
izackp 4aee51762a Add C-family (Objective-C/C/C++) declaration parsing support
Extends symbol extraction to mixed Swift+C-family packages via clangd
multiplexing through sourcekit-lsp: new CFamilyDeclarationParser,
CompilationDatabaseWriter, ObjectiveCShimWriter, SourceLanguage detection,
PathFilter, and ShapeSnapshot, plus a shared DeclarationParser contract and
build/test scripts.
2026-06-15 16:27:19 -04:00

610 lines
22 KiB
Swift

import Foundation
struct ShapeSnapshot: Codable {
let root: String
let files: [String]
let types: [TypeShape]
static func build(symbolTable: SymbolTable, packageRoot: String, pathFilter: String?, excludePaths: [String]) -> ShapeSnapshot {
let filteredFiles = symbolTable.fileTargets.keys.sorted().filter { filePath in
pathIsIncluded(filePath, includePath: pathFilter, excludePaths: excludePaths)
}
let relevantSymbols = filteredFiles.flatMap { symbolTable.fileSymbols[$0] ?? [] }
let relevantExtensions = filteredFiles.flatMap { symbolTable.fileExtensions[$0] ?? [] }
var ownerNames = Set<String>()
for sym in relevantSymbols where sym.typeName.isEmpty && !sym.kind.isMemberKind {
ownerNames.insert(sym.name)
}
for ext in relevantExtensions {
ownerNames.insert(ext.baseType)
}
let types = ownerNames.sorted().map { ownerName -> TypeShape in
let declarations = relevantSymbols
.filter { $0.typeName.isEmpty && $0.name == ownerName && !$0.kind.isMemberKind }
.sorted { lhs, rhs in
if lhs.filePath != rhs.filePath { return lhs.filePath < rhs.filePath }
if lhs.selectionLine != rhs.selectionLine { return lhs.selectionLine < rhs.selectionLine }
return lhs.selectionChar < rhs.selectionChar
}
let members = relevantSymbols.filter { $0.typeName == ownerName }
let extensions = relevantExtensions.filter { $0.baseType == ownerName }
let kind = declarations.first?.kind.rawValue
?? (extensions.isEmpty ? SymbolKind.unknown.rawValue : SymbolKind.extension.rawValue)
let superclass = declarations.lazy.compactMap(\.superclass).first
var conformances = Set<String>()
for decl in declarations {
decl.conformances.forEach { conformances.insert($0) }
}
for ext in extensions {
ext.conformances.forEach { conformances.insert($0) }
}
let nestedTypes = members
.filter { !$0.kind.isMemberKind }
.map { "\($0.kind.rawValue):\($0.name)" }
.sorted()
let properties = members
.filter { $0.kind == .property }
.map(normalizedPropertyKey)
.sorted()
let methods = members
.filter { $0.kind == .function }
.map(normalizedCallableKey)
.sorted()
let initializers = members
.filter { $0.kind == .initializer }
.map(normalizedCallableKey)
.sorted()
return TypeShape(
name: ownerName,
kind: kind,
superclass: superclass,
conformances: conformances.sorted(),
nestedTypes: nestedTypes,
properties: properties,
methods: methods,
initializers: initializers
)
}
let relFiles = filteredFiles.map { filePath in
filePath.hasPrefix(packageRoot + "/")
? String(filePath.dropFirst(packageRoot.count + 1))
: filePath
}
return ShapeSnapshot(root: packageRoot, files: relFiles, types: types)
}
}
struct TypeShape: Codable {
let name: String
let kind: String
let superclass: String?
let conformances: [String]
let nestedTypes: [String]
let properties: [String]
let methods: [String]
let initializers: [String]
}
struct ShapeDiff {
let baselineOnlyTypes: [String]
let candidateOnlyTypes: [String]
let changedTypes: [TypeShapeDiff]
var hasDifferences: Bool {
!baselineOnlyTypes.isEmpty || !candidateOnlyTypes.isEmpty || !changedTypes.isEmpty
}
static func compare(baseline: ShapeSnapshot, candidate: ShapeSnapshot) -> ShapeDiff {
let baselineByName = Dictionary(uniqueKeysWithValues: baseline.types.map { ($0.name, $0) })
let candidateByName = Dictionary(uniqueKeysWithValues: candidate.types.map { ($0.name, $0) })
let baselineNames = Set(baselineByName.keys)
let candidateNames = Set(candidateByName.keys)
let baselineOnlyTypes = baselineNames.subtracting(candidateNames).sorted()
let candidateOnlyTypes = candidateNames.subtracting(baselineNames).sorted()
let changedTypes: [TypeShapeDiff] = baselineNames.intersection(candidateNames).sorted().compactMap { name -> TypeShapeDiff? in
guard let baselineType = baselineByName[name], let candidateType = candidateByName[name] else { return nil }
return TypeShapeDiff.compare(name: name, baseline: baselineType, candidate: candidateType)
}
return ShapeDiff(
baselineOnlyTypes: baselineOnlyTypes,
candidateOnlyTypes: candidateOnlyTypes,
changedTypes: changedTypes
)
}
func render(baselineLabel: String, candidateLabel: String) -> String {
var lines: [String] = []
lines.append("Shape comparison")
lines.append("baseline: \(baselineLabel)")
lines.append("candidate: \(candidateLabel)")
if !hasDifferences {
lines.append("")
lines.append("No shape differences found.")
return lines.joined(separator: "\n") + "\n"
}
if !baselineOnlyTypes.isEmpty {
lines.append("")
lines.append("Only in baseline:")
for name in baselineOnlyTypes {
lines.append(" - \(name)")
}
}
if !candidateOnlyTypes.isEmpty {
lines.append("")
lines.append("Only in candidate:")
for name in candidateOnlyTypes {
lines.append(" - \(name)")
}
}
if !changedTypes.isEmpty {
lines.append("")
lines.append("Changed types:")
for diff in changedTypes {
lines.append(" \(diff.name)")
lines.append(contentsOf: diff.renderDetails().map { " " + $0 })
}
}
return lines.joined(separator: "\n") + "\n"
}
}
struct TypeShapeDiff {
let name: String
let kindMismatch: (String, String)?
let superclassMismatch: (String?, String?)?
let baselineOnlyConformances: [String]
let candidateOnlyConformances: [String]
let baselineOnlyNestedTypes: [String]
let candidateOnlyNestedTypes: [String]
let baselineOnlyProperties: [String]
let candidateOnlyProperties: [String]
let baselineOnlyMethods: [String]
let candidateOnlyMethods: [String]
let baselineOnlyInitializers: [String]
let candidateOnlyInitializers: [String]
var hasDifferences: Bool {
kindMismatch != nil ||
superclassMismatch != nil ||
!baselineOnlyConformances.isEmpty ||
!candidateOnlyConformances.isEmpty ||
!baselineOnlyNestedTypes.isEmpty ||
!candidateOnlyNestedTypes.isEmpty ||
!baselineOnlyProperties.isEmpty ||
!candidateOnlyProperties.isEmpty ||
!baselineOnlyMethods.isEmpty ||
!candidateOnlyMethods.isEmpty ||
!baselineOnlyInitializers.isEmpty ||
!candidateOnlyInitializers.isEmpty
}
static func compare(name: String, baseline: TypeShape, candidate: TypeShape) -> TypeShapeDiff? {
let diff = TypeShapeDiff(
name: name,
kindMismatch: baseline.kind == candidate.kind ? nil : (baseline.kind, candidate.kind),
superclassMismatch: baseline.superclass == candidate.superclass ? nil : (baseline.superclass, candidate.superclass),
baselineOnlyConformances: Set(baseline.conformances).subtracting(candidate.conformances).sorted(),
candidateOnlyConformances: Set(candidate.conformances).subtracting(baseline.conformances).sorted(),
baselineOnlyNestedTypes: Set(baseline.nestedTypes).subtracting(candidate.nestedTypes).sorted(),
candidateOnlyNestedTypes: Set(candidate.nestedTypes).subtracting(baseline.nestedTypes).sorted(),
baselineOnlyProperties: Set(baseline.properties).subtracting(candidate.properties).sorted(),
candidateOnlyProperties: Set(candidate.properties).subtracting(baseline.properties).sorted(),
baselineOnlyMethods: Set(baseline.methods).subtracting(candidate.methods).sorted(),
candidateOnlyMethods: Set(candidate.methods).subtracting(baseline.methods).sorted(),
baselineOnlyInitializers: Set(baseline.initializers).subtracting(candidate.initializers).sorted(),
candidateOnlyInitializers: Set(candidate.initializers).subtracting(baseline.initializers).sorted()
)
return diff.hasDifferences ? diff : nil
}
func renderDetails() -> [String] {
var lines: [String] = []
if let kindMismatch {
lines.append("kind: \(kindMismatch.0) -> \(kindMismatch.1)")
}
if let superclassMismatch {
lines.append("superclass: \(superclassMismatch.0 ?? "nil") -> \(superclassMismatch.1 ?? "nil")")
}
if !baselineOnlyConformances.isEmpty {
lines.append("missing conformances: \(baselineOnlyConformances.joined(separator: ", "))")
}
if !candidateOnlyConformances.isEmpty {
lines.append("extra conformances: \(candidateOnlyConformances.joined(separator: ", "))")
}
if !baselineOnlyNestedTypes.isEmpty {
lines.append("missing nested types: \(baselineOnlyNestedTypes.joined(separator: ", "))")
}
if !candidateOnlyNestedTypes.isEmpty {
lines.append("extra nested types: \(candidateOnlyNestedTypes.joined(separator: ", "))")
}
if !baselineOnlyProperties.isEmpty {
lines.append("missing properties: \(baselineOnlyProperties.joined(separator: ", "))")
}
if !candidateOnlyProperties.isEmpty {
lines.append("extra properties: \(candidateOnlyProperties.joined(separator: ", "))")
}
if !baselineOnlyMethods.isEmpty {
lines.append("missing methods: \(baselineOnlyMethods.joined(separator: ", "))")
}
if !candidateOnlyMethods.isEmpty {
lines.append("extra methods: \(candidateOnlyMethods.joined(separator: ", "))")
}
if !baselineOnlyInitializers.isEmpty {
lines.append("missing initializers: \(baselineOnlyInitializers.joined(separator: ", "))")
}
if !candidateOnlyInitializers.isEmpty {
lines.append("extra initializers: \(candidateOnlyInitializers.joined(separator: ", "))")
}
return lines
}
}
private extension SymbolKind {
var isMemberKind: Bool {
self == .property || self == .function || self == .initializer
}
}
private func normalizedPropertyKey(_ sym: SymbolInfo) -> String {
let name = canonicalIdentifier(sym.name)
if let returnType = sym.returnType, !returnType.isEmpty {
return "\(name):\(normalizeTypeString(returnType))"
}
return name
}
private func normalizedCallableKey(_ sym: SymbolInfo) -> String {
switch detectSignatureLanguage(sym.signature) {
case .swift:
return normalizedSwiftCallableKey(sym)
case .objectiveC:
return normalizedObjCCallableKey(sym)
case .cLike:
return normalizedCLikeCallableKey(sym)
}
}
private enum SignatureLanguage {
case swift
case objectiveC
case cLike
}
private func detectSignatureLanguage(_ signature: String) -> SignatureLanguage {
if signature.contains("func ") || signature.contains("init(") || signature.contains("init<") {
return .swift
}
if signature.hasPrefix("- ") || signature.hasPrefix("+ ") || signature.contains("@property") {
return .objectiveC
}
return .cLike
}
private func normalizedSwiftCallableKey(_ sym: SymbolInfo) -> String {
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
let prefix = sym.kind == .initializer ? "init" : canonicalSwiftBaseName(sym.name)
guard let open = signature.firstIndex(of: "("),
let close = matchingParen(in: signature, open: open)
else {
return prefix
}
let params = String(signature[signature.index(after: open)..<close])
let labels = splitTopLevel(params, separator: ",").map { raw -> String in
let part = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let colon = part.firstIndex(of: ":") else { return "_" }
let labelPart = part[..<colon].trimmingCharacters(in: .whitespacesAndNewlines)
let tokens = labelPart.split(separator: " ")
guard let first = tokens.first else { return "_" }
let label = String(first)
return label == "_" ? "_" : label
}
let canonicalLabels = labels.map { $0 == "_" ? "_" : canonicalIdentifier($0) }
if canonicalLabels.isEmpty || (canonicalLabels.count == 1 && canonicalLabels[0].isEmpty) {
return prefix + "()"
}
return prefix + "(" + canonicalLabels.map { "\($0):" }.joined() + ")"
}
private func normalizedObjCCallableKey(_ sym: SymbolInfo) -> String {
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
guard let close = signature.firstIndex(of: ")") else { return sym.name }
let tail = signature[signature.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines)
if !tail.contains(":") {
let name = tail.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? sym.name
return name
}
let components = splitObjectiveCSelector(tail)
if components.isEmpty {
return canonicalObjectiveCZeroArgName(sym.name)
}
return canonicalObjectiveCCallableKey(components, kind: sym.kind)
}
private func normalizedCLikeCallableKey(_ sym: SymbolInfo) -> String {
let signature = sym.signature.trimmingCharacters(in: .whitespacesAndNewlines)
guard let open = signature.firstIndex(of: "(") else { return sym.name }
let beforeParen = signature[..<open].trimmingCharacters(in: .whitespacesAndNewlines)
let name = beforeParen.split(whereSeparator: \.isWhitespace).last.map(String.init) ?? sym.name
let params = signature[signature.index(after: open)..<(matchingParen(in: signature, open: open) ?? signature.endIndex)]
let arity = splitTopLevel(String(params), separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && $0 != "void" }
.count
return arity == 0 ? "\(name)()" : "\(name)(\(String(repeating: "_:", count: arity)))"
}
private func splitObjectiveCSelector(_ tail: String) -> [String] {
var result: [String] = []
var segment = ""
var parenDepth = 0
var angleDepth = 0
for ch in tail {
switch ch {
case "(":
parenDepth += 1
case ")":
if parenDepth > 0 { parenDepth -= 1 }
case "<":
angleDepth += 1
case ">":
if angleDepth > 0 { angleDepth -= 1 }
case ":":
if parenDepth == 0 && angleDepth == 0 {
if let label = lastIdentifier(in: segment) {
result.append(label)
}
segment = ""
}
default:
segment.append(ch)
}
}
return result
}
private func splitTopLevel(_ text: String, separator: Character) -> [String] {
var parts: [String] = []
var current = ""
var parenDepth = 0
var angleDepth = 0
var bracketDepth = 0
for ch in text {
switch ch {
case "(":
parenDepth += 1
case ")":
if parenDepth > 0 { parenDepth -= 1 }
case "<":
angleDepth += 1
case ">":
if angleDepth > 0 { angleDepth -= 1 }
case "[":
bracketDepth += 1
case "]":
if bracketDepth > 0 { bracketDepth -= 1 }
default:
break
}
if ch == separator && parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 {
parts.append(current)
current = ""
} else {
current.append(ch)
}
}
if !current.isEmpty {
parts.append(current)
}
return parts
}
private func matchingParen(in text: String, open: String.Index) -> String.Index? {
var depth = 0
var index = open
while index < text.endIndex {
let ch = text[index]
if ch == "(" {
depth += 1
} else if ch == ")" {
depth -= 1
if depth == 0 { return index }
}
index = text.index(after: index)
}
return nil
}
private func normalizeTypeString(_ type: String) -> String {
type
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "?", with: "?")
}
private func lastIdentifier(in text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var end = trimmed.endIndex
while end > trimmed.startIndex {
let prev = trimmed.index(before: end)
let scalar = trimmed[prev]
if scalar.isLetter || scalar.isNumber || scalar == "_" {
break
}
end = prev
}
guard end > trimmed.startIndex else { return nil }
var start = end
while start > trimmed.startIndex {
let prev = trimmed.index(before: start)
let scalar = trimmed[prev]
if scalar.isLetter || scalar.isNumber || scalar == "_" {
start = prev
} else {
break
}
}
let identifier = String(trimmed[start..<end])
return identifier.isEmpty ? nil : identifier
}
private func canonicalSwiftBaseName(_ name: String) -> String {
if let open = name.firstIndex(of: "(") {
return canonicalIdentifier(String(name[..<open]))
}
return canonicalIdentifier(name)
}
private func canonicalObjectiveCZeroArgName(_ name: String) -> String {
let canonical = canonicalIdentifier(name)
if canonical.hasPrefix("get"), canonical.count > 3, let dropped = dropGetterPrefix(canonical) {
return dropped + "()"
}
return canonical + "()"
}
private func canonicalObjectiveCCallableKey(_ rawComponents: [String], kind: SymbolKind) -> String {
var components = rawComponents.map(normalizeObjectiveCSelectorComponent)
if let last = components.last, last == "error" {
components.removeLast()
}
guard let first = components.first else {
return kind == .initializer ? "init()" : canonicalIdentifier(rawComponents.first ?? "")
}
if kind == .initializer {
return canonicalObjectiveCInitializerKey(components)
}
if components.count == 1 {
let single = first
if single.hasSuffix("WithError"), let base = single.dropSuffix("WithError") {
if let getter = dropGetterPrefix(base) {
return getter + "()"
}
return base + "()"
}
if single.hasPrefix("set"), single.count > 3 {
return single + "(_:)"
}
return single + "(_:)"
}
if let (base, firstLabel) = splitWithLabel(first) {
let labels = [firstLabel] + Array(components.dropFirst())
return base + "(" + labels.map { "\($0):" }.joined() + ")"
}
return first + "(" + Array(components.dropFirst()).map { "\($0):" }.joined() + ")"
}
private func canonicalObjectiveCInitializerKey(_ components: [String]) -> String {
guard let first = components.first else { return "init()" }
if first == "init" && components.count == 1 {
return "init()"
}
if let suffix = first.dropPrefix("initWith"), !suffix.isEmpty {
let firstLabel = lowercasedFirst(suffix)
let remaining = [firstLabel] + Array(components.dropFirst())
return "init(" + remaining.map { "\($0):" }.joined() + ")"
}
if first == "init" {
return "init(" + Array(components.dropFirst()).map { "\($0):" }.joined() + ")"
}
return "init(" + components.map { "\($0):" }.joined() + ")"
}
private func splitWithLabel(_ value: String) -> (String, String)? {
guard let range = value.range(of: "With"),
range.lowerBound != value.startIndex,
range.upperBound != value.endIndex
else { return nil }
let base = String(value[..<range.lowerBound])
let label = lowercasedFirst(String(value[range.upperBound...]))
guard !base.isEmpty, !label.isEmpty else { return nil }
return (base, label)
}
private func dropGetterPrefix(_ value: String) -> String? {
guard let suffix = value.dropPrefix("get"), !suffix.isEmpty else { return nil }
return lowercasedFirst(suffix)
}
private func canonicalIdentifier(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
if trimmed.contains("_") {
let parts = trimmed.split(separator: "_").map(String.init)
guard let first = parts.first else { return trimmed }
let rest = parts.dropFirst().map { uppercasedFirst($0) }
return ([lowercasedFirst(first)] + rest).joined()
}
return trimmed
}
private func normalizeObjectiveCSelectorComponent(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
var candidate = trimmed
if let close = candidate.lastIndex(of: ")"), close < candidate.index(before: candidate.endIndex) {
candidate = String(candidate[candidate.index(after: close)...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if candidate.contains(" ") {
candidate = candidate.split(whereSeparator: \.isWhitespace).last.map(String.init) ?? candidate
}
return canonicalIdentifier(candidate)
}
private func lowercasedFirst(_ value: String) -> String {
guard let first = value.first else { return value }
return String(first).lowercased() + value.dropFirst()
}
private func uppercasedFirst(_ value: String) -> String {
guard let first = value.first else { return value }
return String(first).uppercased() + value.dropFirst()
}
private extension String {
func dropPrefix(_ prefix: String) -> String? {
guard hasPrefix(prefix) else { return nil }
return String(dropFirst(prefix.count))
}
func dropSuffix(_ suffix: String) -> String? {
guard hasSuffix(suffix) else { return nil }
return String(dropLast(suffix.count))
}
}