wandres.dev
NINJA · IDE completo

C: clangd, compile_commands y debugging

C y C++ en Neovim 0.12: clangd declarado como ficha en la carpeta lsp del runtimepath, compile_commands.json, parser de Treesitter con la API viva, clang-format y depuración con codelldb.

⏱ 14 min

C es el terreno donde clangd brilla: análisis preciso, ir a definición a través de cabeceras y diagnósticos del compilador en tiempo real. La clave está en que clangd sepa cómo compilas tu proyecto. Y en Neovim 0.12 declararlo no requiere ningún plugin: es un fichero de Lua que devuelve una tabla.

🎯 Al terminar esta lección sabrás
  • Declarar clangd en lsp/clangd.lua y activarlo con vim.lsp.enable.
  • Entender por qué clangd necesita compile_commands.json y cómo generarlo.
  • Instalar el parser de C con la API viva de nvim-treesitter.
  • Formatear con clang-format y depurar con codelldb.

El servidor es una ficha, no un plugin

Desde Neovim 0.12 (la estable de hoy es la 0.12.4) un servidor de lenguaje se describe con vim.lsp.config y se activa con vim.lsp.enable. La forma canónica es poner cada servidor en su propio fichero dentro de una carpeta lsp/ del runtimepath: el fichero devuelve la tabla y Neovim lo lee solo cuando ese servidor hace falta.

return {
  cmd = {
    "clangd",
    "--background-index",
    "--clang-tidy",              -- diagnósticos de clang-tidy
    "--header-insertion=iwyu",   -- inserta la cabecera dueña del símbolo
    "--completion-style=detailed",
    "--function-arg-placeholders",
  },
  filetypes = { "c", "cpp", "objc", "objcpp", "cuda" },
  root_markers = {
    ".clangd",
    "compile_commands.json",
    "compile_flags.txt",
    ".git",
  },
  init_options = { fallbackFlags = { "-std=c17" } },
}
vim.lsp.enable("clangd")

Eso es todo: no hay setup(), ni plugin intermediario, ni orden de carga que respetar. Al abrir un .c, Neovim busca los servidores habilitados cuyo filetypes encaje, resuelve la raíz con root_markers, lanza el proceso y adjunta el cliente. Los atajos (gd, K, <leader>rn, ]d y [d) y los diagnósticos ya los montaste en la lección 2.1 sobre el evento LspAttach, así que valen para clangd sin tocar nada.

ℹ️
Dos banderas que ya no hacen falta

En el clangd actual, --background-index y --clang-tidy vienen activados por defecto. Dejarlos escritos no cambia el comportamiento, pero documenta la intención de la configuración; si prefieres una ficha mínima, bórralos sin miedo. El resto sí cambia cosas: --header-insertion=iwyu inserta el #include que corresponde al aceptar un completado, y --function-arg-placeholders rellena los argumentos como huecos de snippet.

💡
Comprobar qué configuración se aplicó de verdad

Cuando algo no arranca, mira la tabla resuelta antes de teorizar: :lua vim.print(vim.lsp.config["clangd"]) te enseña la fusión final, :checkhealth vim.lsp lista los clientes vivos con su raíz, y el comando :lsp de 0.12 te da el estado del cliente en el buffer actual. Casi siempre el problema es un binario que no está en el PATH o una raíz mal detectada.

nvim-lspconfig y Mason, hoy: opcionales

Puedes escribir esa ficha a mano —acabas de hacerlo— o dejar que te la den ya escrita. Ese es exactamente el papel que le queda a nvim-lspconfig: es un catálogo de fichas por defecto, un directorio lsp/ con centenares de ficheros como el de arriba que entra en tu runtimepath con menos precedencia que el tuyo. No es un motor y ya no hace falta para que el LSP funcione.

neovim/nvim-lspconfig
return {
  "neovim/nvim-lspconfig",
  lazy = false,
}
vim.pack.add({
  { src = "https://github.com/neovim/nvim-lspconfig" },
})

Instalarlo no activa nada: solo añade su carpeta lsp al runtimepath. Sigues necesitando tu vim.lsp.enable, y cualquier campo que declares tú gana sobre el suyo.

Con el catálogo instalado, tu ficha se reduce a lo que quieras cambiar:

return {
  cmd = { "clangd", "--header-insertion=iwyu", "--function-arg-placeholders" },
  init_options = { fallbackFlags = { "-std=c17" } },
}

Además, la ficha de clangd del catálogo crea dos comandos de buffer que Neovim no trae: :LspClangdSwitchSourceHeader, para saltar entre .c y .h, y :LspClangdShowSymbolInfo. Si los quieres a mano de teclado:

vim.keymap.set("n", "<leader>ch", "<cmd>LspClangdSwitchSourceHeader<cr>", { desc = "C: cabecera o fuente" })

Mason es la tercera pieza y resuelve un problema distinto: conseguir el binario. Ni declara ni activa nada; instala ejecutables en un directorio propio y lo añade al camino de búsqueda. En C rara vez lo necesitas, porque clangd viene con LLVM y lo instalas con el gestor de paquetes del sistema. Conseguir el binario, describir el servidor y activarlo son tres responsabilidades separadas: confundirlas es el origen de la mayoría de configuraciones enredadas que circulan por ahí.

El corazón: compile_commands.json

clangd necesita saber con qué flags compilas cada archivo (includes, defines, estándar). Esa información vive en un compile_commands.json en la raíz del proyecto. Sin él, clangd adivina —usa el fallbackFlags de tu ficha— y verás falsos errores en los #include.

Con CMake (lo genera solo)

cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# enlaza el json a la raíz para que clangd lo encuentre
ln -s build/compile_commands.json .

Con Makefiles: usa Bear

bear envuelve tu compilación y captura los comandos reales:

# macOS: brew install bear   ·   Debian: apt install bear
bear -- make
💡
Proyectos de un solo archivo

Para pruebas rápidas de un .c suelto no hay base de datos que generar: clangd cae en el fallbackFlags de la ficha. clangd también reconoce un compile_flags.txt en la raíz, que es uno de los marcadores con los que detecta el proyecto. Para cualquier proyecto real, genera el compile_commands.json: es la diferencia entre clangd útil y clangd frustrante.

El parser de C, con la API viva

⚠️
Casi todo lo que encuentres escrito sobre Treesitter está muerto

La rama main es una reescritura incompatible: nvim-treesitter.configs y sus opciones (ensure_installed, highlight, indent, incremental_selection) ya no existen. Lo tienes explicado en la lección 2.3.

Instalaste el plugin en la lección 2.3 con branch = "main", lazy = false y build = ":TSUpdate". Aquí solo añades el parser y enciendes el resaltado para C:

require("nvim-treesitter").install({ "c" })

-- Sin foldlevelstart = 99, cada archivo se abre ENTERO PLEGADO.
-- Ponlo una vez en tus opciones (leccion 2.3).
vim.o.foldlevelstart = 99

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "c", "cpp" },
  callback = function()
    vim.treesitter.start()                                    -- resaltado
    vim.wo[0][0].foldexpr = "v:lua.vim.treesitter.foldexpr()"  -- plegado
    vim.wo[0][0].foldmethod = "expr"
  end,
})

install es asíncrona. Si necesitas que el arranque de una máquina nueva no siga hasta tener el parser, encadena la espera: require("nvim-treesitter").install({ "c" }):wait(300000).

Con el parser puesto, los objetos de texto de la lección 2.3 empiezan a valer en C: dif vacía el cuerpo de una función, vaf la selecciona entera, ]f salta a la siguiente y <leader>cp intercambia dos argumentos de una llamada.

Formateo con clang-format

Ya configuraste c = { "clang_format" } en conform (lección 2.4). El binario clang-format viene con LLVM; instálalo con el gestor de paquetes del sistema o con Mason, que para esto es exactamente lo que necesitas. Personaliza el estilo con un .clang-format en la raíz:

BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: None

Debugging con codelldb

codelldb es el adaptador para lenguajes nativos. Define una configuración de debug para C sobre el nvim-dap de la lección 2.4:

local dap = require("dap")

dap.adapters.codelldb = {
  type = "server",
  port = "${port}",
  executable = {
    command = vim.fn.exepath("codelldb"),
    args = { "--port", "${port}" },
  },
}

dap.configurations.c = {
  {
    name = "Lanzar ejecutable",
    type = "codelldb",
    request = "launch",
    program = function()
      return vim.fn.input("Ruta al ejecutable: ", vim.fn.getcwd() .. "/", "file")
    end,
    cwd = "${workspaceFolder}",
    stopOnEntry = false,
  },
}

Compila con símbolos de depuración (gcc -g -o programa programa.c), pon un breakpoint con <leader>db y arranca con <leader>dc.

clang-tidy es tu revisor gratis

Con clang-tidy activo, clangd te avisa de bugs sutiles: fugas de memoria potenciales, comparaciones peligrosas, usos tras liberar. Configúralo con un .clang-tidy en la raíz activando familias de checks (bugprone-*, performance-*). Fíjate en el patrón: ese fichero, el .clang-format, el compile_commands.json y tu lsp/clangd.lua son todos datos declarativos que viven en el proyecto o en tu configuración, no comportamiento escondido en un plugin. Cuando toda tu configuración tiene esa forma, cambiar de máquina deja de dar miedo y depurar el editor se parece a depurar código.

⚔️ C con superpoderes
  1. Escribe tu lsp/clangd.lua y activa el servidor con una sola línea en init.lua.
  2. Comprueba con :lua vim.print(vim.lsp.config["clangd"]) que la tabla resuelta es la que esperabas.
  3. Genera compile_commands.json en un proyecto C (CMake o Bear) y confirma con :checkhealth vim.lsp que la raíz detectada es la correcta.
  4. Abre un .c: navega con gd a través de un #include hasta la definición.
  5. Instala el parser de C, pliega una función con za y vacía su cuerpo con dif.
  6. Compila con -g, pon un breakpoint y depura con <leader>dc + <leader>du.