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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:35:16 -04:00
parent 4aee51762a
commit fa8590048d
4 changed files with 252 additions and 23 deletions
+119
View File
@@ -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)
}
}
+83
View File
@@ -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<String>()
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"
}
}
}
+39 -21
View File
@@ -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)
}
}
+11 -2
View File
@@ -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 {