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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user