Neovim C/C++ IDE

Default flags for clangd and :make, a Makefile-snippet, and a one-button compile-and-debug keymap.

There are many articles and videos on how to set up Neovim for development but no one goes far enough. Most set up a language server. Sometimes they tinker with a debugger. But then they quickly stop after setting up some basic (and often bad) keymaps. You need to actually do the configuration so that it can be used effectively!

In this article I will present my Neovim C/C++ IDE setup. My goal is a one-button compile-and-debug keymap using my default set of compile-flags for all of my C/C++-projects. These should be proper default settings that are applied without requiring any actions from the user. Furthermore, it should work for single stand-alone C/C++-files without having to add additional files into the project, and for multi-file projects using make.

The Language Server

I use the clangd language server. It helps me to edit code and it displays errors and warnings. It is important that clangd is given a good set of compile-flags so that it outputs, and only outputs, warnings and errors that I think are important.

The first recommended method of setting compile-flags is in a compilation database, compile_commands.json, which is usually generated by a build system. This is already too complicated for my use case and can be ignored.

The second common method is using compile_flags.txt. It is a simple text file where you enter compile-flags, one per line. clangd then applies these flags to all C/C++-files in your project. This is not great because you still have to add this new file to every project.

Thankfully, there is a third method. It is possible to configure clangd with fallback-flags. These are used when neither of the above files exists.

lsp.lua
vim.lsp.config("clangd", {
    cmd = {
        "clangd",
        "--clang-tidy",
        "--background-index",
        "--log=error",  -- clangd is very verbose without this.
    },
    init_options = {
        fallbackFlags = {
            "-Wall", "-Wextra", -- Your flags here...
        }
    }
})

vim.lsp.enable("clangd")

This is exactly what I want. Fallback-flags enables the use of our default set of flags in all of our projects without adding additional files and, when that isn’t enough, project-specific flags can be set using compile_flags.txt.

Creating Makefiles

I find make to be quite good for simple projects and simple Makefiles can be quite easy to both read and write. To make this process easier I created a simple snippet that can easily be autocompleted into any Makefile.

The Makefile is simple and extensible. It builds two targets: main and tests from main.cpp and tests.cpp where tests also depends on main.cpp. main is built as a unity build and it is expected that main.cpp includes everything as one giant file.

snippets/make.json
{
    "basic": {
        "prefix": "basic",
        "body": [
            "#",
            "# A simple Makefile based on a main.cpp and tests.cpp",
            "#",
            "# This Makefile defines the targets `all`, `main` and `tests` where the",
            "# compilation of `tests` is also dependent on main.cpp. It is assumed that",
            "# tests.cpp directly includes `main.cpp` (with a `#define main oldmain` and",
            "# undef after to prevent the declaration of two main functions).",
            "#",
            "CXX = g++",
            "CXXFLAGS =  -Wall -Wextra  # Your flags here...",
            "",
            "# LDFLAGS: Extra flags to give to compilers when they are supposed to invoke",
            "# the linker, 'ld', such as -L. Libraries (-lfoo) should be added to the",
            "# LDLIBS variable instead.",
            "LDFLAGS = ",
            "",
            "# LDLIBS: Library flags or names given to compilers when they are supposed to",
            "# invoke the linker,'ld'. LOADLIBES is a deprecated (but still supported)",
            "# alternative to LDLIBS. Non-library linker flags, such as -L, should go in",
            "# the LDFLAGS variable.",
            "LDLIBS = ",
            "",
            ".PHONY: all",
            "all: main tests",
            "",
            "tests: tests.cpp main.cpp",
            "",
            "%: %.cpp Makefile",
            "\t\\$(CXX) -o \\$@ \\$(CXXFLAGS) \\$(LDFLAGS) \\$(LDLIBS) \\$<"
        ],
        "description": "Basic Makefile with main and tests targets"
    }
}

It expects tests.cpp to include main.cpp (by redefining the main-function) so that it can use the functions defined in main.cpp, like in the following example:

main.cpp
#include <stdio.h>

int fac(int n) {
    if (n <= 0) return 1;
    return n*fac(n-1);
}

int main() {
    int n = 5;
    printf("fac(%d) = %d\n", n, fac(n));
    return 0;
}
tests.cpp
#define main oldmain
#include "main.cpp"
#undef main

#include <assert.h>

int main() {
    assert(fac(5) == 120);
    return 0;
}

Compiling

Neovim provides the command :make1 that builds your project. It runs the command stored in makeprg, a Neovim-variable that is set to make by default. But many small projects do not have a Makefile and adding one should not be necessary (even with the aforementioned Makefile-snippet). In order to compile stand-alone files we must set makeprg ourselves. I have made the function UpdateMakeprg that heuristically updates makeprg.

init.lua
CC = "gcc"
CXX = "g++"

CFLAGS = "<your flags here...>"
CXXFLAGS = CFLAGS

LDFLAGS = ""
LDLIBS = ""

-- Heuristically update `makeprg`.
function UpdateMakeprg()
    local ft = vim.bo.filetype
    local filename = vim.fn.expand("%")
    local filename_root = vim.fn.expand("%:r")

    if FileExists("Makefile") then
        -- Makefile exists. Use make and let the Makefile decide target.
        vim.opt.makeprg = "make"

    elseif ft == "c" then
        vim.opt.makeprg = table.concat(
            {CC, "-o", filename_root, CFLAGS, LDFLAGS, LDLIBS, filename},
            " "
        )

    elseif ft == "cpp" then
        vim.opt.makeprg = table.concat(
            {CXX, "-o", filename_root, CXXFLAGS, LDFLAGS, LDLIBS, filename},
            " "
        )

    elseif ft == "lua" or ft == "python" or ft == "sh" then
        -- Reset makeprg for these file types because otherwise the
        -- "compilation" can fail and the debuggers will not start.
        vim.opt.makeprg = ""

    elseif ft == "tex" then
        vim.opt.makeprg = "pdflatex " .. filename

    else
        -- Unknown case. Default back to make.
        vim.opt.makeprg = "make"
    end
end

The function first checks if there is a Makefile in the current working directory. If there is then it sets makeprg=make. If no Makefile exists and if the current file is file.cpp, it sets

makeprg=g++ -o file <your flags here...> file.cpp

which will compile file.cpp into file using our default set of flags.

I have also hacked together an autocommand for CmdlineLeave2 which is triggered every time the user leaves the command-line. When this autocommand detects that the user is about to execute :make it updates makeprg before :make.3

init.lua
local augroup = vim.api.nvim_create_augroup("UpdateMakeprg", { clear = true })

-- Autocommand that triggers when leaving the command-line.
-- Calls `UpdateMakeprg` when it detects the user is about to `:make`.
vim.api.nvim_create_autocmd("CmdlineLeave", {
    desc = "Update makeprg before :make",
    group = augroup,
    callback = function()
        local abort = vim.v.event.abort
        local cmdtype = vim.v.event.cmdtype
        local cmdline = vim.fn.getcmdline()

        -- Update makeprg if
        --  the command was not aborted
        --  and it was a real command (as opposed to, e.g. / or ?)
        --  and the command-line matches `make` somewhere.
        -- False positives is not a big problem; it just updates makeprg.
        if not abort and cmdtype == ":" and string.match(cmdline, "make") then
            UpdateMakeprg()
        end
    end,
})

Different Sets of Flags

If the program has any issues (fails to compile or has warnings) when :make is executed, Neovim will populate the quickfix-list and jump to the first issue. I like this, in general, but it is annoying in some cases. When I am programming I want to be made aware about minor issues, e.g. unused variables, but I don’t want Neovim to jump to them every time I run :make. I know that it is there! I am going to fix it! But it is not important to fix it right now and jumping to it is a distraction.

It is time to recognize that we probably want to use a different sets of flags for the language server and for the compilation. This way the language server can issue warnings for unused variables and they can be suppressed in the compilation (which will prevent Neovim from jumping to it).

In init.lua I have defined a big table of flags and each has a boolean whether it is to be included in the language server and/or in the compilation.

init.lua
-- Returns .text for each flag that passes the predicate.
function FilterFlags(predicate, flags)
    local new_list = {}
    for _, v in ipairs(flags) do
        if predicate(v) then
            table.insert(new_list, v.text)
        end
    end
    return new_list
end

FLAGS = {
    { text = "-Wall",   compile = true, ls = true },
    { text = "-Wextra", compile = true, ls = true },

    { text = "-Wconversion",         compile = true, ls = true },
    { text = "-Wdouble-promotion",   compile = true, ls = true },
    { text = "-Wno-sign-conversion", compile = true, ls = true },

    { text = "-Wno-unused-parameter", compile = true, ls = true },
    { text = "-Wno-unused-function",  compile = true, ls = true },

    { text = "-Wno-missing-field-initializers", compile = true, ls = true },

    -- Unused variables should not warn when compiling the program because I
    -- hate it when nvim then moves the cursor to it while developing. I know
    -- the variable is unused! I am not done yet!
    --
    -- But the language server should issue a warning for them.
    { text = "-Wno-unused-variable",         compile = true, ls = false },
    { text = "-Wno-unused-but-set-variable", compile = true, ls = false },

    -- Compile-flags that serve no purpose with LS.
    { text = "-g3",                  compile = true, ls = false },
    { text = "-fsanitize-trap",      compile = true, ls = false },
    { text = "-fsanitize=undefined", compile = true, ls = false },
}

Many of the flags come from an article on nullprogram.com.

Edit clangd-configuration and init.lua to use the flags.

lsp.lua
init_options = {
    fallbackFlags = FilterFlags(function(x) return x.ls end, FLAGS),
}
init.lua
CC = "gcc"
CXX = "g++"

CFLAGS = table.concat(
    FilterFlags(function(x) return x.compile end, FLAGS), " "
)
CXXFLAGS = CFLAGS

Finally, the Makefile-snippet can be dynamically generated and saved to file.4

init.lua
-- Create snippet directory if it does not exist.
vim.fn.mkdir(vim.fn.stdpath("config") .. "/snippets", "p")

local file = io.open(vim.fn.stdpath("config") .. "/snippets/make.json", "w")

if file then
    -- Format this JSON with CXX and CXXFLAGS and write it to file.
    local json = [[{
    "basic": {
        "prefix": "basic",
        "body": [
            "# A simple Makefile based on a main.cpp and tests.cpp",
            "CXX = %s",
            "CXXFLAGS = %s",
            "",
            "LDFLAGS = ",
            "LDLIBS = ",
            "",
            ".PHONY: all",
            "all: main tests",
            "",
            "tests: tests.cpp main.cpp",
            "",
            "%%: %%.cpp Makefile",
            "\t\\$(CXX) -o \\$@ \\$(CXXFLAGS) \\$(LDFLAGS) \\$(LDLIBS) \\$<"
        ],
        "description": "Basic Makefile with main and tests targets"
    }
}]]
    json = string.format(json, CXX, CXXFLAGS)
    file:write(json)
    file:close()
else
    vim.print("ERROR: Could not write Makefile-snippet 'basic'")
end

Debugging

When I am working on my C/C++ code I am always running it from the debugger.

The John Carmack-clip above is worth watching in its entirety for the importance of debuggers and IDEs. If you are not using a debugger every single day you are probably not using it often enough.

For debugging in Neovim I use the plugins nvim-dap, nvim-dap-ui, nvim-dap-virtual-text, and GDB as the actual debugger. While GDB perhaps isn’t the best debugger it remains that simple breakpoints, inspecting variables, and stepping through the program, is most of the value a debugger provides. Anything more than that is just extra.

It is important to be able to debug your program and it is important to be able to do so in a simple and fast manner. Previously, I showed how I use :make to build the file under cursor and it is only natural to have the debugger debug the file under cursor.

dap.lua
dap.configurations.c = {
    {
        -- This config runs the program under cursor (`file` if
        -- standing in file.c) with no arguments.
        name = "Debug file under cursor",
        type = "gdb",
        request = "launch",
        program = "${relativeFileDirname}/${fileBasenameNoExtension}",
        cwd = "${workspaceFolder}",
    },
    -- ...
}

-- Use the same configurations for C++.
dap.configurations.cpp = dap.configurations.c

But debugging the file under cursor not enough. It is time to implement a one-button compile-and-debug keymap.

dap.lua
vim.keymap.set(
    "n",  "<F1>", continue_or_start_session, { desc = "(DAP) Continue"  }
)

continue_or_start_session uses :make to compile the project which will either run make or directly compile the file under cursor. If the compilation was successful it will start a debugging session for the file under cursor. Note the super-special crazy exception in there. When I am programming in my own projects (which often use the Makefile-snippet above) the debugger debugs tests instead of the file under cursor. This makes it even easier to start debugging because I don’t have to move the cursor to tests.cpp before running compile-and-debug.

dap.lua
-- Continues the session or starts one if none exist. When a new session is
-- started it first saves all buffers and runs :make. If no errors were
-- detected then a new session is started.
local function continue_or_start_session()
    if dap.session() then  -- Session exists; continue it.
        return dap.continue()
    end

    local compiled = save_and_compile()

    if compiled then
        -- NOTE: Super-special crazy exception.
        -- If programming in C/C++ (this also matches header files),
        --  and `make` is used to compile,
        --  and the file `tests` exists,
        -- then start debugging the program `tests`.
        if (vim.bo.filetype == "c" or vim.bo.filetype == "cpp")
                and vim.opt.makeprg:get() == "make"
                and FileExists("tests") then

            local config = {
                name = "Debug `tests` file",
                type = "gdb",
                request = "launch",
                program = "tests",
                cwd = "${workspaceFolder}",
            }
            return dap.run(config)

        elseif dap.configurations[vim.bo.filetype] then
            -- Configurations for this file type exist.
            -- Run the first one ("Debug file under cursor").
            return dap.run(dap.configurations[vim.bo.filetype][1])

        else
            vim.notify(
                string.format(
                    "[DAP] No dap configuration for filetype: %s",
                    vim.bo.filetype
                ),
                vim.log.levels.WARN
            )
        end
    end
end

The function save_and_compile saves all opened files, updates makeprg, runs :make, and then steps through the quickfix-list to see if the compilation was successful.5

dap.lua
-- Saves all buffers and compiles the program using :make.
-- Returns true if compilation succeeds.
local function save_and_compile()
    -- Save all buffers.
    vim.cmd [[ wa ]]

    -- Update makeprg before compiling.
    UpdateMakeprg()

    -- Use `silent` so we don't have to confirm with <CR>.
    -- Use `!` to prevent it from jumping to the first issue.
    vim.cmd [[ silent make! ]]

    -- Check whether there are any errors in the quickfix-list.
    local qf = vim.fn.getqflist()
    for _, item in ipairs(qf) do
        -- Items with valid ~= 0 are either warnings or errors.
        if item.valid ~= 0 and not item.text:find("warning:") then
            vim.print("QF is full of errors: ", item.text)
            return false
        end
    end
    return true
end

Conclusion

The language server, the :make-command, and the Makefile-snippet, all have a default set of flags with this configuration. The Makefile-snippet can be used to level up a simple stand-alone file, e.g. when testing starts to become more important. And your programs can easily be debugged using the one-button compile-and-debug keymap.

But as I have been writing this article I have made a horrible realization: I have basically turned Neovim into an IDE. This was not something I had realized before writing this article. But I also realize that every tool that I use I try to integrate into Neovim. I have previously mentioned that I use Neogit that integrates Git into Neovim.

An IDE is intended to enhance productivity by providing development features with a consistent user experience as opposed to using separate tools, such as vi, GDB, GCC, and make.

But this article also shows that it requires so much work to implement a feature that basically every IDE has out of the box. This isn’t even all the configuration that you need! You still need keymaps for setting breakpoints, stepping, etc. I think it is still worth it just because of how used I am to the Vim environment and its extensible nature. You could easily take my configuration here and adapt it for your own needs. Create your own super-special crazy exceptions! Having the configuration be done in an actual programming language is incredibly powerful, even if it is Lua.

Future Work

I think what I have configured here is good but it is possible to make it better.

What I now need is better per-project settings and the ability to override them. I would like to be able to set a default target for compile-and-debug per project and the ability to select between multiple targets. It should also support using different starting arguments and record a history of past launches that can easily be relaunched.

I would also like the ability to quickly add or remove flags in both the language server and in the compilation. I have thought of having different modes in the language server. Perhaps you have one set of flags for regular programming but later you can enter a cleanup-mode with a different set of flags with more warnings issued.

Notes

  1. :make refers to the command in Neovim, whereas make refers to the software Make that builds programs using Makefiles. ↩︎

  2. :make runs QuickFixCmdPre autocommands but the documentation explicitly states that it cannot be used to set makeprg↩︎

  3. There are different (and probably better) solutions to this. A previous version of this used an autocommand that updated makeprg on every BufEnter. You could instead create a user-defined command or a filetype plugin. I want it though to update makeprg before every invocation of :make so that the user is able to change the global variables at runtime, e.g. :lua CC="clang" or have it update makeprg to make if a Makefile has been added. ↩︎

  4. It is not ideal to write the snippet to file every time Neovim is launched, potentially overwriting your old snippets. You can add snippets without writing them to file but it is not trivial when using blink.cmp. I also omitted the earlier comments in the snippet for brevity. ↩︎

  5. I would like to use the exit status from makeprg but I don’t know if that is possible. I tried using vim.v.shell_error but it seems like it is not set after :make↩︎