Initial commit — CodeMapper Swift CLI tool

Automated-By: Claude Sonnet 4.6
This commit is contained in:
2026-05-28 22:03:43 -04:00
commit 630d69f02d
9 changed files with 1136 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.build/
+18
View File
@@ -0,0 +1,18 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "CodeMapper",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
],
targets: [
.executableTarget(
name: "CodeMapper",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
)
]
)
+74
View File
@@ -0,0 +1,74 @@
import Foundation
struct CallGraphBuilder {
let lsp: LSPClient
let symbolTable: SymbolTable
func process(filePath: String) throws {
let uri = "file://" + filePath
guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { return }
try lsp.openFile(uri: uri, content: content)
defer { try? lsp.closeFile(uri: uri) }
let fileSyms = symbolTable.fileSymbols[filePath] ?? []
let funcSyms = fileSyms.filter { $0.kind == .function || $0.kind == .initializer }
for sym in funcSyms {
let items: [[String: Any]]
do {
items = try lsp.callHierarchyPrepare(
uri: uri,
line: sym.selectionLine,
character: sym.selectionChar
)
} catch {
continue
}
guard let item = items.first else { continue }
let outgoing = (try? lsp.callHierarchyOutgoing(item: item)) ?? []
for call in outgoing {
guard let toItem = call["to"] as? [String: Any],
let toName = toItem["name"] as? String
else { continue }
let toUri = toItem["uri"] as? String ?? ""
let toDetail = toItem["detail"] as? String ?? ""
let toFilePath = toUri.hasPrefix("file://") ? String(toUri.dropFirst(7)) : toUri
let isExternal = symbolTable.fileTargets[toFilePath] == nil
let calleeQualified: String
let calleeDisplay: String
if isExternal {
calleeQualified = ""
let label = toDetail.isEmpty ? toName : "\(toDetail).\(toName)"
calleeDisplay = "~\(label)"
} else {
let qualified = toDetail.isEmpty ? toName : "\(toDetail).\(toName)"
// Verify the qualified name is actually in our symbol table
if symbolTable.symbols[qualified] != nil {
calleeQualified = qualified
calleeDisplay = qualified
} else if symbolTable.symbols[toName] != nil {
calleeQualified = toName
calleeDisplay = toName
} else {
calleeQualified = qualified
calleeDisplay = qualified
}
}
let edge = CallEdge(
callerQualified: sym.qualifiedName,
calleeQualified: calleeQualified,
calleeDisplay: calleeDisplay,
isExternal: isExternal
)
symbolTable.addCallEdge(edge)
}
}
}
}
+217
View File
@@ -0,0 +1,217 @@
import Foundation
enum LSPError: Error, CustomStringConvertible {
case connectionClosed
case invalidHeader
case invalidJSON
case requestFailed(String)
var description: String {
switch self {
case .connectionClosed: return "LSP connection closed"
case .invalidHeader: return "Invalid LSP header"
case .invalidJSON: return "Invalid LSP JSON"
case .requestFailed(let msg): return "LSP request failed: \(msg)"
}
}
}
class LSPClient {
private let process: Process
private let stdinPipe: Pipe
private let stdoutPipe: Pipe
private var nextId = 1
private var isShutdown = false
let projectRoot: String
init(lspPath: String, projectRoot: String) throws {
self.projectRoot = projectRoot
process = Process()
process.executableURL = URL(fileURLWithPath: lspPath)
stdinPipe = Pipe()
stdoutPipe = Pipe()
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
// Log LSP stderr to /tmp for debugging
let logPath = "/tmp/sourcekit-lsp.log"
FileManager.default.createFile(atPath: logPath, contents: nil)
process.standardError = FileHandle(forWritingAtPath: logPath)
try process.run()
}
deinit {
if !isShutdown { process.terminate() }
}
func initialize() throws {
// Try to find the existing index store for faster call hierarchy
let indexStorePath = findIndexStorePath()
var initOptions: [String: Any] = [:]
if let isp = indexStorePath {
initOptions["indexStorePath"] = isp
initOptions["indexDatabasePath"] = "/tmp/codemapper-lsp-index"
}
let params: [String: Any] = [
"processId": ProcessInfo.processInfo.processIdentifier,
"rootUri": "file://\(projectRoot)",
"capabilities": [
"textDocument": [
"callHierarchy": ["dynamicRegistration": false],
"documentSymbol": ["hierarchicalDocumentSymbolSupport": true]
]
],
"initializationOptions": initOptions
]
_ = try sendRequest("initialize", params: params)
try sendNotification("initialized", params: [:])
if indexStorePath != nil {
// Brief pause for LSP to load the index
Thread.sleep(forTimeInterval: 2.0)
}
}
private func findIndexStorePath() -> String? {
let fm = FileManager.default
let candidates = [
projectRoot + "/.build/arm64-apple-macosx/debug/index/store",
projectRoot + "/.build/debug/index/store",
projectRoot + "/.build/index-build/arm64-apple-macosx/debug/index/store",
]
return candidates.first { fm.fileExists(atPath: $0) }
}
func openFile(uri: String, content: String) throws {
try sendNotification("textDocument/didOpen", params: [
"textDocument": [
"uri": uri,
"languageId": "swift",
"version": 1,
"text": content
] as [String: Any]
])
}
func closeFile(uri: String) throws {
try sendNotification("textDocument/didClose", params: [
"textDocument": ["uri": uri]
])
}
func documentSymbol(uri: String) throws -> [[String: Any]] {
let result = try sendRequest("textDocument/documentSymbol", params: [
"textDocument": ["uri": uri]
])
return result as? [[String: Any]] ?? []
}
func callHierarchyPrepare(uri: String, line: Int, character: Int) throws -> [[String: Any]] {
let result = try sendRequest("textDocument/prepareCallHierarchy", params: [
"textDocument": ["uri": uri],
"position": ["line": line, "character": character]
])
return result as? [[String: Any]] ?? []
}
func callHierarchyOutgoing(item: [String: Any]) throws -> [[String: Any]] {
let result = try sendRequest("callHierarchy/outgoingCalls", params: [
"item": item
])
return result as? [[String: Any]] ?? []
}
func shutdownGracefully() throws {
guard !isShutdown else { return }
isShutdown = true
_ = try? sendRequest("shutdown", params: [:] as [String: Any])
try? sendNotification("exit", params: [:])
process.terminate()
}
// MARK: - JSON-RPC transport
@discardableResult
func sendRequest(_ method: String, params: Any) throws -> Any? {
let id = nextId
nextId += 1
let msg: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
]
try writeMessage(msg)
while true {
let response = try readMessage()
// Check if this is our response (has matching id)
if let respId = response["id"] as? Int, respId == id {
if let error = response["error"] as? [String: Any] {
let msg = error["message"] as? String ?? "unknown error"
throw LSPError.requestFailed(msg)
}
return response["result"]
}
// Discard notification or non-matching response and keep reading
}
}
private func sendNotification(_ method: String, params: Any) throws {
let msg: [String: Any] = [
"jsonrpc": "2.0",
"method": method,
"params": params
]
try writeMessage(msg)
}
private func writeMessage(_ msg: [String: Any]) throws {
let body = try JSONSerialization.data(withJSONObject: msg)
let header = "Content-Length: \(body.count)\r\n\r\n"
var data = header.data(using: .utf8)!
data.append(body)
stdinPipe.fileHandleForWriting.write(data)
}
private func readMessage() throws -> [String: Any] {
let contentLength = try readContentLength()
let body = stdoutPipe.fileHandleForReading.readData(ofLength: contentLength)
guard body.count == contentLength else { throw LSPError.connectionClosed }
guard let obj = try JSONSerialization.jsonObject(with: body) as? [String: Any] else {
throw LSPError.invalidJSON
}
return obj
}
private func readContentLength() throws -> Int {
var header = Data()
let fh = stdoutPipe.fileHandleForReading
while true {
let byte = fh.readData(ofLength: 1)
guard !byte.isEmpty else { throw LSPError.connectionClosed }
header.append(byte)
if header.count >= 4 {
let tail = header.suffix(4)
if tail == Data([0x0D, 0x0A, 0x0D, 0x0A]) { break } // \r\n\r\n
}
}
let headerStr = String(data: header, encoding: .utf8) ?? ""
for line in headerStr.components(separatedBy: "\r\n") {
if line.lowercased().hasPrefix("content-length:") {
let value = line.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)
if let length = Int(value) { return length }
}
}
throw LSPError.invalidHeader
}
}
+247
View File
@@ -0,0 +1,247 @@
import Foundation
struct OutputWriter {
let symbolTable: SymbolTable
let packageRoot: String
var includeCalls: Bool = true
var outgoingOnly: Bool = false
var pathFilter: String? = nil
func printAll() throws {
let allFiles = symbolTable.fileTargets.keys.sorted().filter { filePath in
guard let filter = pathFilter else { return true }
return filePath == filter || filePath.hasPrefix(filter + "/")
}
for filePath in allFiles {
Swift.print(buildOutput(for: filePath))
}
}
func writeAll() throws {
let allFiles = symbolTable.fileTargets.keys.sorted().filter { filePath in
guard let filter = pathFilter else { return true }
return filePath == filter || filePath.hasPrefix(filter + "/")
}
for filePath in allFiles {
let output = buildOutput(for: filePath)
let mapPath = filePath + ".map"
try output.write(toFile: mapPath, atomically: true, encoding: .utf8)
}
}
func buildOutput(for filePath: String) -> String {
let targetName = symbolTable.fileTargets[filePath] ?? "unknown"
let relPath = filePath.hasPrefix(packageRoot + "/")
? String(filePath.dropFirst(packageRoot.count + 1))
: filePath
var lines: [String] = []
lines.append("# \(relPath) [\(targetName)]")
let fileSyms = symbolTable.fileSymbols[filePath] ?? []
let extensions = symbolTable.fileExtensions[filePath] ?? []
// Top-level types defined in this file
let topLevelTypes = fileSyms.filter { $0.typeName.isEmpty && $0.kind != .function && $0.kind != .initializer && $0.kind != .property }
// Top-level functions (not inside any type)
let topLevelFuncs = fileSyms.filter { $0.typeName.isEmpty && ($0.kind == .function || $0.kind == .initializer) }
if topLevelTypes.isEmpty && extensions.isEmpty && topLevelFuncs.isEmpty {
return lines.joined(separator: "\n") + "\n"
}
lines.append("")
for typeSym in topLevelTypes {
lines.append(contentsOf: typeBlock(typeSym, fileSyms: fileSyms))
lines.append("")
}
for ext in extensions {
lines.append(contentsOf: extensionBlock(ext, fileSyms: fileSyms))
lines.append("")
}
for funcSym in topLevelFuncs {
lines.append(contentsOf: funcLines(funcSym, indent: ""))
lines.append("")
}
// Trim trailing blank line
while lines.last == "" { lines.removeLast() }
return lines.joined(separator: "\n") + "\n"
}
private func typeBlock(_ sym: SymbolInfo, fileSyms: [SymbolInfo]) -> [String] {
var lines: [String] = []
// Type header line
lines.append(typeHeaderLine(sym))
// Merged conformances (including those from extensions in other files)
let allConformances = symbolTable.conformanceMap[sym.name] ?? []
let declaredConformances = Set(sym.conformances + (sym.superclass.map { [$0] } ?? []))
let extraConformances = allConformances.subtracting(declaredConformances).sorted()
if !extraConformances.isEmpty {
// Show additional conformances added via extensions
lines.append(" // + \(extraConformances.joined(separator: " "))")
}
// Implementors for protocols
if sym.kind == .protocol {
let impls = symbolTable.implementorMap[sym.name] ?? []
if !impls.isEmpty {
lines.append(" impl: \(impls.joined(separator: " "))")
}
}
// Members
let members = fileSyms.filter { $0.typeName == sym.name }
let props = members.filter { $0.kind == .property }
let funcs = members.filter { $0.kind == .function || $0.kind == .initializer }
let nestedTypes = members.filter { $0.typeName == sym.name && $0.kind != .function && $0.kind != .initializer && $0.kind != .property }
for prop in props {
lines.append(" \(propLine(prop))")
}
for f in funcs {
lines.append(contentsOf: funcLines(f, indent: " "))
}
for nested in nestedTypes {
for l in typeBlock(nested, fileSyms: fileSyms) {
lines.append(" " + l)
}
}
return lines
}
private func extensionBlock(_ ext: ExtensionInfo, fileSyms: [SymbolInfo]) -> [String] {
var lines: [String] = []
var header = "ext \(ext.baseType)"
if !ext.conformances.isEmpty {
header += " | \(ext.conformances.joined(separator: " "))"
}
lines.append(header)
let members = fileSyms.filter { $0.typeName == ext.baseType }
let props = members.filter { $0.kind == .property }
let funcs = members.filter { $0.kind == .function || $0.kind == .initializer }
for prop in props {
lines.append(" \(propLine(prop))")
}
for f in funcs {
lines.append(contentsOf: funcLines(f, indent: " "))
}
return lines
}
private func typeHeaderLine(_ sym: SymbolInfo) -> String {
var header = sym.name
switch sym.kind {
case .class, .actor:
if let sup = sym.superclass {
header += " < \(sup)"
}
let protos = sym.conformances
if !protos.isEmpty {
header += " | \(protos.joined(separator: " "))"
}
case .struct, .enum, .protocol:
if !sym.conformances.isEmpty {
header += " | \(sym.conformances.joined(separator: " "))"
}
default: break
}
return header
}
private func propLine(_ sym: SymbolInfo) -> String {
let detail = sym.returnType ?? ""
let prefix = sym.accessLevel.isEmpty ? "" : sym.accessLevel + " "
if detail.isEmpty {
return "\(prefix)\(sym.name)"
}
return "\(prefix)\(sym.name): \(detail)"
}
private func funcLines(_ sym: SymbolInfo, indent: String) -> [String] {
var lines: [String] = []
// Build function declaration line
var decl = ""
if !sym.accessLevel.isEmpty { decl += sym.accessLevel + " " }
// Extract clean signature (strip qualifier prefix)
let cleanSig = cleanFuncSignature(sym)
decl += cleanSig
if sym.isAsync { decl += " async" }
if sym.isThrows { decl += "!" }
if let ret = sym.returnType, !sym.isAsync || !decl.contains(ret) {
if !decl.contains("-> ") { decl += " -> \(ret)" }
}
lines.append(indent + decl)
if includeCalls {
let allOutgoing = symbolTable.callEdges[sym.qualifiedName] ?? []
let outgoing = sym.typeName.isEmpty ? allOutgoing : allOutgoing.filter {
!$0.calleeQualified.hasPrefix(sym.typeName + ".")
}
if !outgoing.isEmpty {
let displays = outgoing.map { $0.calleeDisplay }
lines.append(indent + " >> \(displays.joined(separator: " "))")
}
if !outgoingOnly {
let incoming = symbolTable.reverseEdges[sym.qualifiedName] ?? []
if !incoming.isEmpty {
let sorted = incoming.sorted()
lines.append(indent + " << \(sorted.joined(separator: " "))")
}
}
}
return lines
}
private func cleanFuncSignature(_ sym: SymbolInfo) -> String {
var sig = sym.signature
// Strip access modifiers from start
for mod in ["public ", "open ", "private ", "fileprivate ", "internal ",
"static ", "class ", "override ", "final ", "mutating ", "nonmutating ",
"required ", "convenience ", "dynamic ", "lazy ",
"@discardableResult ", "@objc ", "@available"] {
while sig.hasPrefix(mod) { sig = String(sig.dropFirst(mod.count)) }
}
// Strip leading effect keywords before the name (they're encoded separately)
for kw in ["async throws ", "async rethrows ", "throws async ", "throws ",
"rethrows ", "async "] {
if sig.hasPrefix(kw) { sig = String(sig.dropFirst(kw.count)) }
}
// Strip `func ` / `init` prefix
if sig.hasPrefix("func ") { sig = String(sig.dropFirst(5)) }
// Remove `-> ReturnType` from end (encoded separately)
if let arrowRange = sig.range(of: " ->", options: .backwards) {
sig = String(sig[..<arrowRange.lowerBound])
}
// Remove trailing `async throws` etc.
for suffix in [" async throws", " async rethrows", " throws", " rethrows", " async"] {
if sig.hasSuffix(suffix) { sig = String(sig.dropLast(suffix.count)) }
}
return sig.trimmingCharacters(in: .whitespaces)
}
}
+104
View File
@@ -0,0 +1,104 @@
import Foundation
struct TargetInfo {
let name: String
let sourcePath: String // absolute path
}
struct SourceWalker {
let packageRoot: String
let filter: String?
func discoverTargets() -> [TargetInfo] {
let pkgPath = packageRoot + "/Package.swift"
guard let content = try? String(contentsOfFile: pkgPath, encoding: .utf8) else {
return []
}
var targets: [TargetInfo] = []
let patterns = [
#"\.(?:executable|test)?[Tt]arget\s*\(\s*name:\s*"([^"]+)""#,
]
for pattern in patterns {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
let nsContent = content as NSString
let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length))
for match in matches {
guard match.numberOfRanges > 1 else { continue }
let nameRange = match.range(at: 1)
let targetName = nsContent.substring(with: nameRange)
// Look for explicit path: argument near this target declaration
let searchStart = match.range.location
let searchEnd = min(searchStart + 500, nsContent.length)
let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart)
let searchStr = nsContent.substring(with: searchRange)
var sourcePath: String
if let pathMatch = try? NSRegularExpression(pattern: #"path:\s*"([^"]+)""#)
.firstMatch(in: searchStr, range: NSRange(location: 0, length: (searchStr as NSString).length)) {
let pathRange = pathMatch.range(at: 1)
let relPath = (searchStr as NSString).substring(with: pathRange)
sourcePath = packageRoot + "/" + relPath
} else {
sourcePath = packageRoot + "/Sources/" + targetName
}
targets.append(TargetInfo(name: targetName, sourcePath: sourcePath))
}
}
return targets
}
func discoverFiles(symbolTable: SymbolTable) -> [(filePath: String, targetName: String)] {
let targets = discoverTargets()
var results: [(String, String)] = []
let fm = FileManager.default
for target in targets {
if let filter = filter, target.name != filter { continue }
guard let enumerator = fm.enumerator(
at: URL(fileURLWithPath: target.sourcePath),
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { continue }
for case let url as URL in enumerator {
guard url.pathExtension == "swift" else { continue }
let path = url.path
guard !path.contains("/.build/") else { continue }
symbolTable.fileTargets[path] = target.name
results.append((path, target.name))
}
}
// If filter is set but no explicit target matched, fall back to scanning Sources/
if results.isEmpty, let filter = filter {
let fallbackPath = packageRoot + "/Sources/" + filter
guard let enumerator = fm.enumerator(
at: URL(fileURLWithPath: fallbackPath),
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else { return [] }
for case let url as URL in enumerator {
guard url.pathExtension == "swift" else { continue }
let path = url.path
symbolTable.fileTargets[path] = filter
results.append((path, filter))
}
}
return results.sorted { $0.0 < $1.0 }
}
func targetName(for filePath: String, targets: [TargetInfo]) -> String {
let sorted = targets.sorted { $0.sourcePath.count > $1.sourcePath.count }
for target in sorted {
if filePath.hasPrefix(target.sourcePath) { return target.name }
}
return "unknown"
}
}
+273
View File
@@ -0,0 +1,273 @@
import Foundation
struct SymbolExtractor {
let lsp: LSPClient
let symbolTable: SymbolTable
func process(filePath: String, targetName: String) throws {
let uri = "file://" + filePath
guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { return }
let sourceBytes = Array(content.utf8)
let lineOffsets = buildLineOffsets(content)
try lsp.openFile(uri: uri, content: content)
defer { try? lsp.closeFile(uri: uri) }
let rawSymbols = try lsp.documentSymbol(uri: uri)
flatten(rawSymbols, parent: nil, filePath: filePath, targetName: targetName,
sourceBytes: sourceBytes, lineOffsets: lineOffsets)
}
private func flatten(
_ symbols: [[String: Any]],
parent: String?,
filePath: String,
targetName: String,
sourceBytes: [UInt8],
lineOffsets: [Int]
) {
for raw in symbols {
guard let name = raw["name"] as? String,
let kind = raw["kind"] as? Int,
let rangeDict = raw["range"] as? [String: Any],
let selDict = raw["selectionRange"] as? [String: Any]
else { continue }
let rangeStart = position(rangeDict["start"] as? [String: Any])
let selStart = position(selDict["start"] as? [String: Any])
let startOffset = offsetFor(line: rangeStart.line, char: rangeStart.char, offsets: lineOffsets)
let sig = extractSignature(sourceBytes: sourceBytes, startOffset: startOffset)
let parsed = parseSignature(sig, lspKind: kind, name: name)
let qualifiedName = parent.map { "\($0).\(name)" } ?? name
if parsed.isExtension {
// Register extension metadata; methods inside will use base type as parent
let ext = ExtensionInfo(baseType: name, conformances: parsed.conformances)
symbolTable.registerExtension(ext, filePath: filePath)
// Recurse with name as the "parent" so methods get attributed to the base type
let children = raw["children"] as? [[String: Any]] ?? []
flatten(children, parent: name, filePath: filePath, targetName: targetName,
sourceBytes: sourceBytes, lineOffsets: lineOffsets)
continue
}
// Skip enum cases, type aliases, imports etc.
guard parsed.kind != .unknown else {
let children = raw["children"] as? [[String: Any]] ?? []
flatten(children, parent: parent ?? name, filePath: filePath, targetName: targetName,
sourceBytes: sourceBytes, lineOffsets: lineOffsets)
continue
}
let sym = SymbolInfo(
qualifiedName: qualifiedName,
typeName: parent ?? "",
name: name,
kind: parsed.kind,
signature: sig,
filePath: filePath,
targetName: targetName,
isExtensionDecl: false,
accessLevel: parsed.accessLevel,
isAsync: parsed.isAsync,
isThrows: parsed.isThrows,
returnType: parsed.returnType,
superclass: parsed.superclass,
conformances: parsed.conformances,
selectionLine: selStart.line,
selectionChar: selStart.char
)
symbolTable.registerSymbol(sym)
let children = raw["children"] as? [[String: Any]] ?? []
let childParent = (parsed.kind == .function || parsed.kind == .initializer || parsed.kind == .property)
? parent
: name
flatten(children, parent: childParent, filePath: filePath, targetName: targetName,
sourceBytes: sourceBytes, lineOffsets: lineOffsets)
}
}
// MARK: - Helpers
private func position(_ dict: [String: Any]?) -> (line: Int, char: Int) {
guard let d = dict else { return (0, 0) }
return (d["line"] as? Int ?? 0, d["character"] as? Int ?? 0)
}
private func buildLineOffsets(_ content: String) -> [Int] {
var offsets = [0]
let bytes = content.utf8
for (i, b) in bytes.enumerated() where b == 0x0A {
offsets.append(i + 1)
}
return offsets
}
private func offsetFor(line: Int, char: Int, offsets: [Int]) -> Int {
guard line < offsets.count else { return offsets.last ?? 0 }
return offsets[line] + char
}
private func extractSignature(sourceBytes: [UInt8], startOffset: Int) -> String {
var parenDepth = 0
var bracketDepth = 0
var i = startOffset
while i < sourceBytes.count {
let b = sourceBytes[i]
if b == UInt8(ascii: "{") && parenDepth == 0 && bracketDepth == 0 { break }
// Stop at newline for protocol requirements / computed property stubs
if b == UInt8(ascii: "\n") && parenDepth == 0 && bracketDepth == 0 {
// Check if next non-whitespace is `{` if not, this is a one-liner with no body
var j = i + 1
while j < sourceBytes.count && (sourceBytes[j] == 0x20 || sourceBytes[j] == 0x09) { j += 1 }
if j < sourceBytes.count && sourceBytes[j] != UInt8(ascii: "{") { break }
}
switch b {
case UInt8(ascii: "("): parenDepth += 1
case UInt8(ascii: ")"): if parenDepth > 0 { parenDepth -= 1 }
case UInt8(ascii: "["): bracketDepth += 1
case UInt8(ascii: "]"): if bracketDepth > 0 { bracketDepth -= 1 }
default: break
}
i += 1
}
let sigBytes = Array(sourceBytes[startOffset..<i])
let raw = String(bytes: sigBytes, encoding: .utf8) ?? ""
return raw.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespaces)
}
struct ParsedDecl {
var kind: SymbolKind
var accessLevel: String
var isAsync: Bool
var isThrows: Bool
var isExtension: Bool
var returnType: String?
var superclass: String?
var conformances: [String]
}
private func parseSignature(_ sig: String, lspKind: Int, name: String) -> ParsedDecl {
var result = ParsedDecl(
kind: lspKindToSymbolKind(lspKind, sig: sig),
accessLevel: "",
isAsync: false,
isThrows: false,
isExtension: sig.hasPrefix("extension ") || sig.contains(" extension "),
returnType: nil,
superclass: nil,
conformances: []
)
// Access level
let lowSig = sig
if lowSig.contains("public ") || lowSig.contains("open ") {
result.accessLevel = "pub"
} else if lowSig.contains("private ") || lowSig.contains("fileprivate ") {
result.accessLevel = "priv"
}
result.isAsync = sig.contains(" async") || sig.contains(" async\n")
result.isThrows = sig.contains(" throws") || sig.contains(" rethrows")
// Return type after last `->`
if let arrowRange = sig.range(of: "->", options: .backwards) {
let candidate = String(sig[arrowRange.upperBound...]).trimmingCharacters(in: .whitespaces)
if !candidate.isEmpty { result.returnType = candidate }
}
// Inheritance / conformances after `:` in type/extension declarations
if result.kind == .class || result.kind == .struct || result.kind == .enum ||
result.kind == .actor || result.kind == .protocol || result.isExtension {
if let colonRange = findColon(in: sig, after: name) {
let inherited = String(sig[sig.index(after: colonRange)...])
.trimmingCharacters(in: .whitespaces)
let parts = inherited.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && !$0.contains(" where ") }
.map { stripGenericConstraints($0) }
.filter { !$0.isEmpty }
if result.kind == .class && !parts.isEmpty {
// Heuristic: first item is superclass for class declarations
// Only if it looks like a concrete type (starts uppercase, no Protocol suffix convention)
let first = parts[0]
result.superclass = first
result.conformances = parts.count > 1 ? Array(parts[1...]) : []
} else {
result.conformances = parts
}
}
}
return result
}
private func lspKindToSymbolKind(_ kind: Int, sig: String) -> SymbolKind {
if sig.hasPrefix("extension ") || sig.contains(" extension ") { return .extension }
switch kind {
case 5: // Class also used for actors sometimes
if sig.contains("actor ") { return .actor }
return .class
case 6, 12: return .function // Method, Function
case 7, 8: return .property // Property, Field
case 9: return .initializer
case 10: return .enum
case 11: return .protocol // Interface
case 13: return .property // Variable
case 23: return .struct
default:
if sig.contains("actor ") { return .actor }
if sig.contains("struct ") { return .struct }
if sig.contains("class ") { return .class }
if sig.contains("enum ") { return .enum }
if sig.contains("protocol ") { return .protocol }
if sig.contains("func ") { return .function }
if sig.contains("init(") || sig.contains("init<") { return .initializer }
return .unknown
}
}
private func findColon(in sig: String, after name: String) -> String.Index? {
// Find `:` that comes after the type name, outside angle brackets
var depth = 0
var i = sig.startIndex
// Skip past the name first
if let nameRange = sig.range(of: name) {
i = nameRange.upperBound
}
while i < sig.endIndex {
let c = sig[i]
switch c {
case "<": depth += 1
case ">": if depth > 0 { depth -= 1 }
case ":":
if depth == 0 { return i }
case "(":
// Once we're inside parameter list, no more inheritance
return nil
default: break
}
i = sig.index(after: i)
}
return nil
}
private func stripGenericConstraints(_ s: String) -> String {
// Remove trailing generic constraints like `where T: Something`
if let whereRange = s.range(of: " where ") {
return String(s[..<whereRange.lowerBound]).trimmingCharacters(in: .whitespaces)
}
return s
}
}
+96
View File
@@ -0,0 +1,96 @@
import Foundation
enum SymbolKind: String {
case `class`, `struct`, `enum`, `protocol`, actor
case `extension` = "ext"
case function = "func"
case initializer = "init"
case property = "prop"
case unknown = "?"
}
struct SymbolInfo {
var qualifiedName: String // "Application.addWindow" or "Application"
var typeName: String // parent type ("Application"), empty for top-level types
var name: String // simple name
var kind: SymbolKind
var signature: String // sliced source text from decl start to `{`
var filePath: String
var targetName: String
var isExtensionDecl: Bool
var accessLevel: String // "pub", "priv", or "" (internal)
var isAsync: Bool
var isThrows: Bool
var returnType: String?
var superclass: String?
var conformances: [String]
var selectionLine: Int
var selectionChar: Int
}
struct CallEdge {
var callerQualified: String
var calleeQualified: String // "" if external
var calleeDisplay: String // "~name" prefix if external
var isExternal: Bool
}
struct ExtensionInfo {
var baseType: String
var conformances: [String]
}
class SymbolTable {
var symbols: [String: SymbolInfo] = [:]
var fileSymbols: [String: [SymbolInfo]] = [:]
var fileExtensions: [String: [ExtensionInfo]] = [:]
var fileTargets: [String: String] = [:]
var callEdges: [String: [CallEdge]] = [:]
var reverseEdges: [String: [String]] = [:]
var conformanceMap: [String: Set<String>] = [:]
var implementorMap: [String: [String]] = [:]
func registerSymbol(_ sym: SymbolInfo) {
if sym.kind != .extension {
symbols[sym.qualifiedName] = sym
}
fileSymbols[sym.filePath, default: []].append(sym)
let owner = sym.typeName.isEmpty ? sym.name : sym.typeName
for proto in sym.conformances {
conformanceMap[owner, default: []].insert(proto)
}
}
func registerExtension(_ ext: ExtensionInfo, filePath: String) {
fileExtensions[filePath, default: []].append(ext)
for proto in ext.conformances {
conformanceMap[ext.baseType, default: []].insert(proto)
}
}
func addCallEdge(_ edge: CallEdge) {
callEdges[edge.callerQualified, default: []].append(edge)
}
func buildReverseIndex() {
reverseEdges = [:]
for (caller, edges) in callEdges {
for edge in edges where !edge.isExternal {
reverseEdges[edge.calleeQualified, default: []].append(caller)
}
}
}
func buildImplementorMap() {
implementorMap = [:]
for (typeName, protos) in conformanceMap {
for proto in protos {
implementorMap[proto, default: []].append(typeName)
}
}
for key in implementorMap.keys {
implementorMap[key]?.sort()
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
import ArgumentParser
struct CodeMapper: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "CodeMapper",
abstract: "Generate LLM-friendly architectural maps from Swift source files."
)
@Option(name: .long, help: "Root of the Swift package to analyze.")
var sources: String
@Option(name: .long, help: "Only process this target name.")
var filter: String?
@Option(name: .long, help: "Path to sourcekit-lsp binary.")
var lspPath: String = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp"
@Option(name: .long, help: "Only write .map files for paths under this folder or matching this file.")
var path: String?
@Flag(name: .long, help: "Omit >> and << call graph lines (faster, fewer tokens).")
var noCalls: Bool = false
@Flag(name: .long, help: "Show only outgoing >> calls, omit << incoming callers.")
var outgoingOnly: Bool = false
@Flag(name: .long, help: "Print map output to stdout instead of writing .map files.")
var stdout: Bool = false
mutating func run() throws {
let packageRoot = (sources as NSString).standardizingPath
log("CodeMapper starting...")
log("Package root: \(packageRoot)")
log("LSP: \(lspPath)")
let symbolTable = SymbolTable()
let walker = SourceWalker(packageRoot: packageRoot, filter: filter)
let files = walker.discoverFiles(symbolTable: symbolTable)
guard !files.isEmpty else {
log("No Swift files found.")
return
}
log("Found \(files.count) Swift files.")
let lsp = try LSPClient(lspPath: lspPath, projectRoot: packageRoot)
log("Initializing LSP...")
try lsp.initialize()
log("LSP ready.")
let extractor = SymbolExtractor(lsp: lsp, symbolTable: symbolTable)
log("Pass 1: extracting symbols...")
for (i, (filePath, targetName)) in files.enumerated() {
if (i + 1) % 20 == 0 { log(" \(i + 1)/\(files.count)") }
do {
try extractor.process(filePath: filePath, targetName: targetName)
} catch {
fputs("Warning: symbol extraction failed for \(filePath): \(error)\n", stderr)
}
}
log(" \(files.count)/\(files.count) done.")
if !noCalls {
let callBuilder = CallGraphBuilder(lsp: lsp, symbolTable: symbolTable)
log("Pass 1b: building call graph (may be slow on first run)...")
for (i, (filePath, _)) in files.enumerated() {
if (i + 1) % 10 == 0 { log(" \(i + 1)/\(files.count)") }
do {
try callBuilder.process(filePath: filePath)
} catch {
fputs("Warning: call graph failed for \(filePath): \(error)\n", stderr)
}
}
log(" \(files.count)/\(files.count) done.")
}
log("Pass 2: building reverse index...")
symbolTable.buildReverseIndex()
symbolTable.buildImplementorMap()
let resolvedPath = path.map { ($0 as NSString).standardizingPath }
let writer = OutputWriter(symbolTable: symbolTable, packageRoot: packageRoot, includeCalls: !noCalls, outgoingOnly: outgoingOnly, pathFilter: resolvedPath)
if stdout {
try writer.printAll()
} else {
log("Writing .map files...")
try writer.writeAll()
log("Done. Wrote \(symbolTable.fileTargets.count) .swift.map files.")
}
try lsp.shutdownGracefully()
}
private func log(_ msg: String) {
fputs(msg + "\n", stderr)
}
}
// Ignore SIGPIPE so LSP subprocess death returns an error instead of killing this process
signal(SIGPIPE, SIG_IGN)
CodeMapper.main()