Initial commit — CodeMapper Swift CLI tool
Automated-By: Claude Sonnet 4.6
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -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"),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user