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 {
|
class LSPClient {
|
||||||
private let process: Process
|
private let process: Process
|
||||||
private let stdinPipe: Pipe
|
private let stdinPipe: Pipe
|
||||||
@@ -23,6 +32,7 @@ class LSPClient {
|
|||||||
private var nextId = 1
|
private var nextId = 1
|
||||||
private var isShutdown = false
|
private var isShutdown = false
|
||||||
let projectRoot: String
|
let projectRoot: String
|
||||||
|
private let serverMode: LSPServerMode
|
||||||
|
|
||||||
/// Diagnostics pushed by the server via `textDocument/publishDiagnostics`
|
/// Diagnostics pushed by the server via `textDocument/publishDiagnostics`
|
||||||
/// notifications, keyed by document URI. The server computes these
|
/// 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.
|
/// arrive interleaved with (or before) the next request's response.
|
||||||
private var diagnosticsByURI: [String: [[String: Any]]] = [:]
|
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.projectRoot = projectRoot
|
||||||
|
self.serverMode = serverMode
|
||||||
|
|
||||||
process = Process()
|
process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: lspPath)
|
process.executableURL = URL(fileURLWithPath: lspPath)
|
||||||
|
|
||||||
if let compileCommandsDir {
|
switch serverMode {
|
||||||
// Forwarded through sourcekit-lsp to its internal clangd so
|
case .sourcekitLsp:
|
||||||
// C-family files are parsed with the correct driver language
|
if let compileCommandsDir {
|
||||||
// (see CompilationDatabaseWriter). We deliberately don't force
|
// Forwarded through sourcekit-lsp to its internal clangd so
|
||||||
// `--default-workspace-type compilationDatabase` — that bypasses
|
// C-family files are parsed with the correct driver language
|
||||||
// SwiftPM build-system integration entirely, which would degrade
|
// (see CompilationDatabaseWriter). We deliberately don't force
|
||||||
// Swift-side indexing/call-hierarchy in mixed Swift+C-family
|
// `--default-workspace-type compilationDatabase` — that bypasses
|
||||||
// packages (the primary use case) for no diagnostic benefit:
|
// SwiftPM build-system integration entirely, which would degrade
|
||||||
// sourcekit-lsp doesn't forward clangd's publishDiagnostics for
|
// Swift-side indexing/call-hierarchy in mixed Swift+C-family
|
||||||
// C-family files in either workspace mode (verified empirically —
|
// packages (the primary use case) for no diagnostic benefit:
|
||||||
// not even injected syntax errors produce a notification).
|
// sourcekit-lsp doesn't forward clangd's publishDiagnostics for
|
||||||
process.arguments = ["-Xclangd", "-compile-commands-dir=\(compileCommandsDir)"]
|
// 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()
|
stdinPipe = Pipe()
|
||||||
@@ -71,13 +87,15 @@ class LSPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initialize() throws {
|
func initialize() throws {
|
||||||
// Try to find the existing index store for faster call hierarchy
|
|
||||||
let indexStorePath = findIndexStorePath()
|
|
||||||
|
|
||||||
var initOptions: [String: Any] = [:]
|
var initOptions: [String: Any] = [:]
|
||||||
if let isp = indexStorePath {
|
|
||||||
initOptions["indexStorePath"] = isp
|
// Index-store discovery only applies to sourcekit-lsp (Swift packages).
|
||||||
initOptions["indexDatabasePath"] = "/tmp/codemapper-lsp-index"
|
// 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] = [
|
let params: [String: Any] = [
|
||||||
@@ -94,8 +112,8 @@ class LSPClient {
|
|||||||
_ = try sendRequest("initialize", params: params)
|
_ = try sendRequest("initialize", params: params)
|
||||||
try sendNotification("initialized", params: [:])
|
try sendNotification("initialized", params: [:])
|
||||||
|
|
||||||
if indexStorePath != nil {
|
if case .sourcekitLsp = serverMode, !initOptions.isEmpty {
|
||||||
// Brief pause for LSP to load the index
|
// Brief pause for sourcekit-lsp to load the index store
|
||||||
Thread.sleep(forTimeInterval: 2.0)
|
Thread.sleep(forTimeInterval: 2.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ import ArgumentParser
|
|||||||
|
|
||||||
let buildSignature = "CODEMAPPER-SIGNATURE-izackp"
|
let buildSignature = "CODEMAPPER-SIGNATURE-izackp"
|
||||||
|
|
||||||
struct CodeMapper: ParsableCommand {
|
struct CodeMapperCLI: ParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "CodeMapper",
|
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."
|
abstract: "Generate LLM-friendly architectural maps from Swift source files."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -173,7 +182,7 @@ if CommandLine.arguments.contains("--signature") {
|
|||||||
exit(0)
|
exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
CodeMapper.main()
|
CodeMapperCLI.main()
|
||||||
|
|
||||||
private extension JSONEncoder {
|
private extension JSONEncoder {
|
||||||
static var pretty: JSONEncoder {
|
static var pretty: JSONEncoder {
|
||||||
|
|||||||
Reference in New Issue
Block a user