How to Use SonarQube in Neovim

This is an article I wanted to read many years ago, but no one ever wrote anything similar. It took me several years to configure SonarQube in Neovim, and now that it works,I want to teach the step-by-step process and all the details that need to be taught.

To be clear, SonarQube is poorly documented, and what saves it is that it is open source. I myself had to read the source code to find out how it works when everything could be documented. I am talking about connected mode, initial configurations, and client extensions.

Connected mode was the last thing I managed to configure before leaving Soluções Digitais and now I want to pass down this hidden knowledge forward.

Installation

Before anything else, you need to install SonarQube on your operating system; you can install it as a local program in /home/user/.local or as a global program in /usr/local/bin; the preference is yours.

Clone the sonarqube-makefile project to your machine and build the program.

git clone https://codeberg.org/cizordj/sonarqube-makefile.git
cd sonarqube-makefile
make

To install SonarQube as a local program, run the command:

env PREFIX="$HOME/.local" DESTDIR="$HOME/.local/share/sonarqube" make install

To uninstall, run the command:

env PREFIX="$HOME/.local" DESTDIR="$HOME/.local/share/sonarqube" make uninstall

To install SonarQube as a global program, run the command:

sudo make install

To uninstall, run the command:

sudo make uninstall

Configuration

This is where things get tricky! SonarQube is a program that requires MANY client extensions, which is why it doesn’t work out of the box. You can implement these extensions yourself or you can use ready-made plugins that do this for you; if you want to use any plugin, I recommend Sonarlint.nvim as it is very well implemented. However, this article does not teach working with the aforementioned plugin, so we will use a more manual configuration.

Install the plugin nvim-lspconfig and put the following in your init.lua file.

local function get_snake_case_current_folder()
  local cwd = vim.fn.getcwd()
  local folder_name = cwd:match("^.+/(.+)$") or cwd
  local snake_case = folder_name:gsub("%s+", "_"):gsub("[^%w_]", ""):lower()
  return snake_case
end

local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')

--- @class lspconfig.Config
configs.sonarqube = {
  default_config = {
    cmd = { "sonarqube-lsp" },
    autostart = true,
    handlers = {
      ["window/logMessage"] = function(_, params)
        vim.print(params.message)
      end,
      ["sonarlint/readyForTests"] = function()
        vim.notify("Sonarqube has started", vim.log.levels.INFO)
      end,
      ["sonarlint/reportConnectionCheckResult"] = function(_, params)
        local connectionId = params.connectionId
        local reason = params.reason
        local success = params.success
        if success then
          vim.notify(
            string.format(
              "Connected successfully to %s!",
              connectionId
            ),
            vim.log.levels.INFO
          )
        else
          vim.notify(
            string.format(
              "Connection to %s failed! Reason: %s",
              connectionId,
              reason
            ),
            vim.log.levels.ERROR
          )
        end
      end,
      ["sonarlint/isOpenInEditor"] = function(_, params)
        local file_path = vim.uri_to_fname(params[1])
        for _, buf in ipairs(vim.api.nvim_list_bufs()) do
          if vim.api.nvim_buf_is_loaded(buf) and vim.api.nvim_buf_get_name(buf) == file_path then
            return true
          end
        end
        return false
      end,
      ["sonarlint/filterOutExcludedFiles"] = function(_, params)
        return params
      end,
      ["sonarlint/getTokenForServer"] = function(_, params)
        local serverUrl = params[1]
        -- Return the token based on the serverUrl
        return serverUrl
      end,
      ["sonarlint/listFilesInFolder"] = function(_, params)
        local folderUri = vim.uri_to_fname(params.folderUri)
        local files = {
          foundFiles = {}
        }
        local uv = vim.loop

        -- Open the folder
        local handle = uv.fs_scandir(folderUri)
        if not handle then
          vim.notify("Cannot open folder: " .. folderUri, vim.log.levels.ERROR)
          return files
        end

        while true do
          local name, type = uv.fs_scandir_next(handle)
          if not name then
            break
          end
          if type == "file" then
            table.insert(files.foundFiles,
              {
                fileName = name,
                filePath = folderUri
              }
            )
          end
        end

        return files
      end
    },
    detached = false,
    filetypes = {
      'java',
      'javascript',
      'javascriptreact',
      'typescript',
      'typescriptreact',
      'css',
      'html',
      'python',
      'cpp',
      'c',
      'php',
      'dockerfile'
    },
    root_dir = function(fname)
      if vim.version()['minor'] == 9
      then
        return lspconfig.util.find_git_ancestor(fname)
      else
        return vim.fs.root(0, '.git')
      end
    end,
    settings = {
      sonarlint = {
        -- connectedMode = {
        --   project = {
        --     projectKey = "",
        --     connectionId = ""
        --   },
        --   connections = {
        --     sonarqube = {
        --       {
        --         serverUrl = "",
        --         token = "",
        --         connectionId = ""
        --       },
        --     }
        --   }
        -- },
        showAnalyzerLogs = false,
        showVerboseLogs = false,
        disableTelemetry = false,
        ["files.exclude"] = {
          ["**/.git"] = true,
          ["**/node_modules"] = true,
        },
        rules = vim.empty_dict()
      }
    },
    init_options = {
      productKey = "neovim",
      productName = "Neovim",
      productVersion = tostring(vim.version()),
      workspaceName = get_snake_case_current_folder(),
      firstSecretDetected = false,
      showVerboseLogs = false,
      platform = vim.loop.os_uname().sysname,
      architecture = vim.loop.os_uname().machine,
    },
    single_file_support = false
  },
  docs = {
    description = 'An advanced linter in your IDE for Clean Code',
  }
}
lspconfig.sonarqube.setup({
  on_attach = function(client, bufnr)
    common.setKeymaps(bufnr, client.name)
  end,
  flags = {
    debounce_text_changes = 3000
  },
})

It’s a pretty big configuration, isn’t it? This happens because SonarQube requires a lot of client-side implementation and thus a lot of programming is needed. The first thing the language server demands is the initialization configuration (init_options); in it, you pass some information about the IDE you are using and the workspace name (workspaceName); SonarQube uses this to create cache for the project and feed its telemetry.

This is why it’s important that quite a few people enable telemetry, as SonarSource can offer better support for Neovim.

The second most important part lies in the “handlers” key. Notice that Neovim needs to handle many specific events like, for example, sonarlint/isOpenInEditor. If you don’t implement these responses, SonarQube simply won’t work. There are countless requests that Sonar can make to your editor, but not all of them are documented, and you only find out about them by running it in verbose mode while reading the logs.

Connected Mode

To activate the connected mode for SonarQube, you need to fill out the following within the settings key.

local settings = {
  sonarlint = {
    connectedMode = {
      project = {
        projectKey = "",
        connectionId = ""
      },
      connections = {
        sonarqube = {
          {
            serverUrl = "",
            token = "",
            connectionId = ""
          },
        }
      }
    }
  }
}

For information like projectKey, serverUrl, and token, you get it directly from the site where your SonarQube Cloud instance is hosted. As for the connectionId, it is a unique value used to identify this connection, and it is up to you what goes in it.

Also remember that you need to implement a response for the sonarlint/getTokenForServer event.

local handlers = {
    ["sonarlint/getTokenForServer"] = function(_, params)
        -- Return your token here
        return "1234"
    end
}

Although the token is already in the static configuration, you still need to return it during SonarQube execution. The token from the static configuration has no effect, but it is required by Sonar.

Conclusion

This configuration is not perfect and leaves room for many improvements, but with it, you can already work on a daily basis and deliver your requirements. It is possible to create an entire ecosystem around SonarQube, such as a credential storage system, an assistant for configuring connected mode, and an automatic installer. However, this demands a lot of time and willingness; hence the existence of projects like Marc which aim to implement all possible events and make the SonarQube configuration smoother.