commit 630d69f02d8e7498bd702ee6388eff3eb69aefad Author: Isaac Paul Date: Thu May 28 22:03:43 2026 -0400 Initial commit — CodeMapper Swift CLI tool Automated-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b32bf71 --- /dev/null +++ b/Package.swift @@ -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"), + ] + ) + ] +) diff --git a/Sources/CodeMapper/CallGraphBuilder.swift b/Sources/CodeMapper/CallGraphBuilder.swift new file mode 100644 index 0000000..157061e --- /dev/null +++ b/Sources/CodeMapper/CallGraphBuilder.swift @@ -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) + } + } + } +} diff --git a/Sources/CodeMapper/LSPClient.swift b/Sources/CodeMapper/LSPClient.swift new file mode 100644 index 0000000..08e55d7 --- /dev/null +++ b/Sources/CodeMapper/LSPClient.swift @@ -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 + } +} diff --git a/Sources/CodeMapper/OutputWriter.swift b/Sources/CodeMapper/OutputWriter.swift new file mode 100644 index 0000000..3318224 --- /dev/null +++ b/Sources/CodeMapper/OutputWriter.swift @@ -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[.. [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" + } +} diff --git a/Sources/CodeMapper/SymbolExtractor.swift b/Sources/CodeMapper/SymbolExtractor.swift new file mode 100644 index 0000000..cf62aff --- /dev/null +++ b/Sources/CodeMapper/SymbolExtractor.swift @@ -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.. 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[..] = [:] + 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() + } + } +} diff --git a/Sources/CodeMapper/main.swift b/Sources/CodeMapper/main.swift new file mode 100644 index 0000000..8ca0027 --- /dev/null +++ b/Sources/CodeMapper/main.swift @@ -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()