From fa8590048d2739018b6efd08a9f62387dc9de8fe Mon Sep 17 00:00:00 2001 From: izackp Date: Sat, 20 Jun 2026 08:35:16 -0400 Subject: [PATCH] Add clangd server mode and CppMapper subcommand for C/C++ support Introduces LSPServerMode to support both sourcekit-lsp (Swift) and clangd directly (C/C++ via CMake). Adds CppMapper subcommand and restructures CLI under CodeMapperCLI with swift/cpp subcommands. Co-Authored-By: Claude Sonnet 4.6 --- Sources/CodeMapper/CppMapper.swift | 119 +++++++++++++++++++++++ Sources/CodeMapper/CppSourceWalker.swift | 83 ++++++++++++++++ Sources/CodeMapper/LSPClient.swift | 60 ++++++++---- Sources/CodeMapper/main.swift | 13 ++- 4 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 Sources/CodeMapper/CppMapper.swift create mode 100644 Sources/CodeMapper/CppSourceWalker.swift diff --git a/Sources/CodeMapper/CppMapper.swift b/Sources/CodeMapper/CppMapper.swift new file mode 100644 index 0000000..c872f7e --- /dev/null +++ b/Sources/CodeMapper/CppMapper.swift @@ -0,0 +1,119 @@ +import Foundation +import ArgumentParser + +struct CppMapper: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cpp", + abstract: "Generate LLM-friendly architectural maps from a C/C++ project via compile_commands.json." + ) + + @Option(name: .long, help: "Path to compile_commands.json (clang JSON Compilation Database).") + var compileCommands: String + + @Option(name: .long, help: "Path to clangd binary.") + var clangdPath: String = "/usr/bin/clangd" + + @Option(name: .long, help: "Only write .map files for paths under this folder or matching this file.") + var path: String? + + @Option(name: .long, parsing: .upToNextOption, help: "Exclude files under this folder or matching this file. Repeat for multiple exclusions.") + var excludePath: [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 resolvedCompileCommands = (compileCommands as NSString).standardizingPath + let resolvedPath = path.map { ($0 as NSString).standardizingPath } + let resolvedExcludePaths = excludePath.map { ($0 as NSString).standardizingPath } + + log("CppMapper starting...") + log("compile_commands.json: \(resolvedCompileCommands)") + log("clangd: \(clangdPath)") + + let walker = CppSourceWalker(compileCommandsPath: resolvedCompileCommands) + let symbolTable = SymbolTable() + + let discoveredFiles = try walker.discoverFiles(symbolTable: symbolTable) + let files = discoveredFiles.filter { filePath, _, _ in + pathIsIncluded(filePath, includePath: resolvedPath, excludePaths: resolvedExcludePaths) + } + + guard !files.isEmpty else { + log("No matching C/C++ source files found in compile_commands.json.") + return + } + log("Found \(files.count) C/C++ files.") + + // clangd is pointed directly at the compile_commands.json directory; + // it resolves include paths and compiler flags from that database. + let lsp = try LSPClient( + lspPath: clangdPath, + projectRoot: walker.compileCommandsDir, + serverMode: .clangd(compileCommandsDir: walker.compileCommandsDir) + ) + log("Initializing clangd...") + try lsp.initialize() + log("clangd ready.") + + let extractor = SymbolExtractor(lsp: lsp, symbolTable: symbolTable) + + log("Pass 1: extracting symbols...") + for (i, (filePath, targetName, language)) in files.enumerated() { + if (i + 1) % 20 == 0 { log(" \(i + 1)/\(files.count)") } + do { + try extractor.process(filePath: filePath, targetName: targetName, language: language) + } 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...") + 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 writer = OutputWriter( + symbolTable: symbolTable, + packageRoot: walker.compileCommandsDir, + includeCalls: !noCalls, + outgoingOnly: outgoingOnly, + pathFilter: resolvedPath, + excludePaths: resolvedExcludePaths + ) + + if stdout { + try writer.printAll() + } else { + log("Writing .map files...") + try writer.writeAll() + log("Done. Wrote \(symbolTable.fileTargets.count) .map files.") + } + + try lsp.shutdownGracefully() + } + + private func log(_ msg: String) { + fputs(msg + "\n", stderr) + } +} diff --git a/Sources/CodeMapper/CppSourceWalker.swift b/Sources/CodeMapper/CppSourceWalker.swift new file mode 100644 index 0000000..bdacc89 --- /dev/null +++ b/Sources/CodeMapper/CppSourceWalker.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Reads a `compile_commands.json` (clang JSON Compilation Database) and +/// extracts the ordered, deduplicated list of source files to map. Each entry +/// already carries the full compiler invocation (include paths, defines, std +/// flags), so clangd can resolve every symbol without any further setup. +struct CppSourceWalker { + let compileCommandsPath: String + + /// Returns the absolute path to the directory containing compile_commands.json, + /// used as the clangd `--compile-commands-dir` argument. + var compileCommandsDir: String { + (compileCommandsPath as NSString).deletingLastPathComponent + } + + /// Parses compile_commands.json and returns a deduplicated file list in the + /// order they appear in the database. Files with unrecognized extensions are + /// silently skipped. Symlinks are resolved via `standardizingPath` so + /// duplicates introduced by build-system path aliasing are collapsed. + func discoverFiles(symbolTable: SymbolTable) throws -> [(filePath: String, targetName: String, language: SourceLanguage)] { + let data = try Data(contentsOf: URL(fileURLWithPath: compileCommandsPath)) + guard let entries = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw CppSourceWalkerError.invalidFormat(compileCommandsPath) + } + + var files: [(filePath: String, targetName: String, language: SourceLanguage)] = [] + var seen = Set() + + for entry in entries { + guard let rawFile = entry["file"] as? String else { continue } + + // Resolve relative paths against the entry's "directory" field. + let absPath: String + if (rawFile as NSString).isAbsolutePath { + absPath = (rawFile as NSString).standardizingPath + } else { + let dir = entry["directory"] as? String ?? compileCommandsDir + absPath = ((dir + "/" + rawFile) as NSString).standardizingPath + } + + guard !seen.contains(absPath) else { continue } + seen.insert(absPath) + + let ext = (absPath as NSString).pathExtension + guard let language = SourceLanguage(pathExtension: ext), + case .cFamily = language else { continue } + + // Use the directory component as a logical "target" name so the + // OutputWriter can group symbols — mirrors how SourceWalker assigns + // SwiftPM target names to Swift files. + let targetName = inferTargetName(from: absPath) + symbolTable.fileTargets[absPath] = targetName + files.append((filePath: absPath, targetName: targetName, language: language)) + } + + return files + } + + /// Derives a short logical name from the file's directory path. For a CMake + /// project rooted at `compile_commands.json`'s directory, strips that prefix + /// and uses the first path component as the target name (e.g. "libs" from + /// "src/libs/scanner/ScannerService.cpp"). Falls back to the immediate + /// parent directory name. + private func inferTargetName(from filePath: String) -> String { + let dir = compileCommandsDir + let rel = filePath.hasPrefix(dir + "/") + ? String(filePath.dropFirst(dir.count + 1)) + : filePath + let components = rel.split(separator: "/") + return components.first.map(String.init) ?? (filePath as NSString).lastPathComponent + } +} + +enum CppSourceWalkerError: Error, CustomStringConvertible { + case invalidFormat(String) + + var description: String { + switch self { + case .invalidFormat(let path): + return "compile_commands.json at \(path) is not a JSON array of objects" + } + } +} diff --git a/Sources/CodeMapper/LSPClient.swift b/Sources/CodeMapper/LSPClient.swift index 8620541..9ab9aa3 100644 --- a/Sources/CodeMapper/LSPClient.swift +++ b/Sources/CodeMapper/LSPClient.swift @@ -16,6 +16,15 @@ enum LSPError: Error, CustomStringConvertible { } } +enum LSPServerMode { + /// sourcekit-lsp — used for Swift packages. clangd flags are forwarded + /// via `-Xclangd`. Index-store discovery is attempted for faster call graphs. + case sourcekitLsp + /// clangd directly — used for pure C/C++ projects (e.g. CMake). Flags are + /// passed directly; no index-store discovery. + case clangd(compileCommandsDir: String) +} + class LSPClient { private let process: Process private let stdinPipe: Pipe @@ -23,6 +32,7 @@ class LSPClient { private var nextId = 1 private var isShutdown = false let projectRoot: String + private let serverMode: LSPServerMode /// Diagnostics pushed by the server via `textDocument/publishDiagnostics` /// notifications, keyed by document URI. The server computes these @@ -33,24 +43,30 @@ class LSPClient { /// arrive interleaved with (or before) the next request's response. private var diagnosticsByURI: [String: [[String: Any]]] = [:] - init(lspPath: String, projectRoot: String, compileCommandsDir: String? = nil) throws { + init(lspPath: String, projectRoot: String, compileCommandsDir: String? = nil, serverMode: LSPServerMode = .sourcekitLsp) throws { self.projectRoot = projectRoot + self.serverMode = serverMode process = Process() process.executableURL = URL(fileURLWithPath: lspPath) - if let compileCommandsDir { - // Forwarded through sourcekit-lsp to its internal clangd so - // C-family files are parsed with the correct driver language - // (see CompilationDatabaseWriter). We deliberately don't force - // `--default-workspace-type compilationDatabase` — that bypasses - // SwiftPM build-system integration entirely, which would degrade - // Swift-side indexing/call-hierarchy in mixed Swift+C-family - // packages (the primary use case) for no diagnostic benefit: - // sourcekit-lsp doesn't forward clangd's publishDiagnostics for - // C-family files in either workspace mode (verified empirically — - // not even injected syntax errors produce a notification). - process.arguments = ["-Xclangd", "-compile-commands-dir=\(compileCommandsDir)"] + switch serverMode { + case .sourcekitLsp: + if let compileCommandsDir { + // Forwarded through sourcekit-lsp to its internal clangd so + // C-family files are parsed with the correct driver language + // (see CompilationDatabaseWriter). We deliberately don't force + // `--default-workspace-type compilationDatabase` — that bypasses + // SwiftPM build-system integration entirely, which would degrade + // Swift-side indexing/call-hierarchy in mixed Swift+C-family + // packages (the primary use case) for no diagnostic benefit: + // sourcekit-lsp doesn't forward clangd's publishDiagnostics for + // C-family files in either workspace mode (verified empirically — + // not even injected syntax errors produce a notification). + process.arguments = ["-Xclangd", "-compile-commands-dir=\(compileCommandsDir)"] + } + case .clangd(let compileCommandsDir): + process.arguments = ["--compile-commands-dir=\(compileCommandsDir)"] } stdinPipe = Pipe() @@ -71,13 +87,15 @@ class LSPClient { } 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" + + // Index-store discovery only applies to sourcekit-lsp (Swift packages). + // clangd builds its own index from compile_commands.json. + if case .sourcekitLsp = serverMode { + if let isp = findIndexStorePath() { + initOptions["indexStorePath"] = isp + initOptions["indexDatabasePath"] = "/tmp/codemapper-lsp-index" + } } let params: [String: Any] = [ @@ -94,8 +112,8 @@ class LSPClient { _ = try sendRequest("initialize", params: params) try sendNotification("initialized", params: [:]) - if indexStorePath != nil { - // Brief pause for LSP to load the index + if case .sourcekitLsp = serverMode, !initOptions.isEmpty { + // Brief pause for sourcekit-lsp to load the index store Thread.sleep(forTimeInterval: 2.0) } } diff --git a/Sources/CodeMapper/main.swift b/Sources/CodeMapper/main.swift index 5d04be5..c39d7c8 100644 --- a/Sources/CodeMapper/main.swift +++ b/Sources/CodeMapper/main.swift @@ -3,9 +3,18 @@ import ArgumentParser let buildSignature = "CODEMAPPER-SIGNATURE-izackp" -struct CodeMapper: ParsableCommand { +struct CodeMapperCLI: ParsableCommand { static let configuration = CommandConfiguration( commandName: "CodeMapper", + abstract: "Generate LLM-friendly architectural maps from source files.", + subcommands: [CodeMapper.self, CppMapper.self], + defaultSubcommand: CodeMapper.self + ) +} + +struct CodeMapper: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "swift", abstract: "Generate LLM-friendly architectural maps from Swift source files." ) @@ -173,7 +182,7 @@ if CommandLine.arguments.contains("--signature") { exit(0) } -CodeMapper.main() +CodeMapperCLI.main() private extension JSONEncoder { static var pretty: JSONEncoder {