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