Newer
Older
dotfiles / .config / lite-xl / plugins / lsp / diagnostics.lua
-- Store diagnostic messages received by an LSP.
-- @copyright Jefferson Gonzalez
-- @license MIT

local core = require "core"
local config = require "core.config"
local util = require "plugins.lsp.util"
local Timer = require "plugins.lsp.timer"

---@class lsp.diagnostics
local diagnostics = {}

---@class lsp.diagnostics.position
---@field line integer
---@field character integer

---@class lsp.diagnostics.range
---@field start lsp.diagnostics.position
---@field end lsp.diagnostics.position

---@class lsp.diagnostics.severity
---@field ERROR integer
---@field WARNING integer
---@field INFO integer
---@field HINT integer
diagnostics.severity = {
  ERROR = 1,
  WARNING = 2,
  INFO = 3,
  HINT = 4
}

---@alias lsp.diagnostics.severity_code
---|>`diagnostics.severity.ERROR`
---| `diagnostics.severity.WARNING`
---| `diagnostics.severity.INFO`
---| `diagnostics.severity.HINT`

---@class lsp.diagnostics.code_description
---@field href string

---@class lsp.diagnostics.tag
---@field UNNECESSARY integer
---@field DEPRECATED integer
diagnostics.tag = {
  UNNECESSARY = 1,
  DEPRECATED = 2
}

---@alias lsp.diagnostics.tag_code
---|>`diagnostics.tag.UNNECESSARY`
---| `diagnostics.tag.DEPRECATED`

---@class lsp.diagnostics.location
---@field uri string
---@field range lsp.diagnostics.range

---@class lsp.diagnostics.related_information
---@field location lsp.diagnostics.location
---@field message string

---A diagnostic message.
---@class lsp.diagnostics.message
---@field filename string
---@field range lsp.diagnostics.position
---@field severity lsp.diagnostics.severity_code | integer
---@field code integer | string
---@field codeDescription lsp.diagnostics.code_description
---@field source string
---@field message string
---@field tags lsp.diagnostics.tag_code[]
---@field relatedInformation lsp.diagnostics.related_information

---A diagnostic item.
---@class lsp.diagnostics.item
---@field filename string
---@field messages lsp.diagnostics.message[]

---@type table<integer, lsp.diagnostics.item>
diagnostics.list = {}

---@type integer
diagnostics.count = 0

-- Try to load lintplus plugin if available for diagnostics rendering
local lintplus_found, lintplus = nil, nil
if config.plugins.lintplus ~= false then
  lintplus_found, lintplus = pcall(require, "plugins.lintplus")
end
local lintplus_kinds = { "error", "warning", "info", "hint" }

---List of linplus coroutines to delay messages population
---@type table<string,lsp.timer>
local lintplus_delays = {}

---Used to set proper diagnostic type on lintplus
---@type table<integer, string>
diagnostics.lintplus_kinds = lintplus_kinds

---@type boolean
diagnostics.lintplus_found = lintplus_found

---@param a lsp.diagnostics.message
---@param b lsp.diagnostics.message
local function sort_helper(a, b)
  return a.severity < b.severity
end

---Helper to catch some trange occurances where nil is given as filename
---@param filename string|nil
---@return string | nil
local function get_absolute_path(filename)
  if not filename then
    core.error(
      "[LSP Diagnostics]: nil filename given",
      tostring(filename)
    )
    return nil
  end
  return core.project_absolute_path(filename)
end

---Get the position of diagnostics associated to a file.
---@param filename string
---@return integer | nil
function diagnostics.get_index(filename)
  ---@cast filename +nil
  filename = get_absolute_path(filename)
  if not filename then return nil end
  for index, diagnostic in ipairs(diagnostics.list) do
    if diagnostic.filename == filename then
      return index
    end
  end
  return nil
end

---Get the diagnostics associated to a file.
---@param filename string
---@param severity? lsp.diagnostics.severity_code | integer
---@return lsp.diagnostics.message[] | nil
function diagnostics.get(filename, severity)
  ---@cast filename +nil
  filename = get_absolute_path(filename)
  if not filename then return nil end
  for _, diagnostic in ipairs(diagnostics.list) do
    if diagnostic.filename == filename then
      if not severity then return diagnostic.messages end

      local results = {}
      for _, message in ipairs(diagnostic.messages) do
        if message.severity == severity then table.insert(results, message) end
      end

      return #results > 0 and results or nil
    end
  end
  return nil
end

---Adds a new list of diagnostics associated to a file replacing previous one.
---@param filename string
---@param messages lsp.diagnostics.message[]
---@return boolean
function diagnostics.add(filename, messages)
  local index = diagnostics.get_index(filename)

  ---@cast filename +nil
  filename = get_absolute_path(filename)
  if not filename then return false end

  table.sort(messages, sort_helper)

  if not index then
    diagnostics.count = diagnostics.count + 1
    table.insert(diagnostics.list, {
      filename = filename, messages = messages
    })
  else
    diagnostics.list[index].messages = messages
  end

  return true
end

---Removes all diagnostics associated to a file.
---@param filename string
function diagnostics.clear(filename)
  local index = diagnostics.get_index(filename)

  if index then
    table.remove(diagnostics.list, index)
    diagnostics.count = diagnostics.count - 1
  end
end

---Get the amount of diagnostics associated to a file.
---@param filename string
---@param severity? lsp.diagnostics.severity_code | integer
function diagnostics.get_messages_count(filename, severity)
  local index = diagnostics.get_index(filename)

  if not index then return 0 end

  if not severity then return #diagnostics.list[index].messages end

  local count = 0
  for _, message in ipairs(diagnostics.list[index].messages) do
    if message.severity == severity then count = count + 1 end
  end

  return count
end

---@param doc core.doc
function diagnostics.lintplus_init_doc(doc)
  if lintplus_found then
    lintplus.init_doc(doc.filename, doc)
  end
end

---Remove registered diagnostics from lintplus for the given file or for
---all files if no filename is given.
---@param filename? string
---@param force boolean
function diagnostics.lintplus_clear_messages(filename, force)
  if lintplus_found then
    if
      not force and lintplus_delays[filename]
      and
      lintplus_delays[filename]:running()
    then
      return
    end
    if filename then
      lintplus.clear_messages(filename)
    else
      for fname, _ in pairs(lintplus.messages) do
        if lintplus_delays[fname] then
          lintplus_delays[fname]:stop()
          lintplus_delays[fname] = nil
        end
        lintplus.clear_messages(fname)
      end
    end
  end
end

---@param filename string
function diagnostics.lintplus_populate(filename)
  if lintplus_found then
    diagnostics.lintplus_clear_messages(filename, true)

    if not filename then
      for _, diagnostic in ipairs(diagnostics.list) do
        local fname = core.normalize_to_project_dir(diagnostic.filename)
        for _, message in pairs(diagnostic.messages) do
          local line, col = util.toselection(message.range)
          local text = message.message
          local kind = lintplus_kinds[message.severity]

          lintplus.add_message(fname, line, col, kind, text)
        end
      end
    else
      local messages = diagnostics.get(filename)
      if messages then
        for _, message in pairs(messages) do
          local line, col = util.toselection(message.range)
          local text = message.message
          local kind = lintplus_kinds[message.severity]

          lintplus.add_message(
            core.normalize_to_project_dir(filename),
            line, col, kind, text
          )
        end
      end
    end
  end
end

---@param filename string
---@param user_typed boolean
function diagnostics.lintplus_populate_delayed(filename)
  if lintplus_found then
    if not lintplus_delays[filename] then
      lintplus_delays[filename] = Timer(
        config.plugins.lsp.diagnostics_delay or 500,
        true
      )
      lintplus_delays[filename].on_timer = function()
        diagnostics.lintplus_populate(filename)
        lintplus_delays[filename] = nil
      end
      lintplus_delays[filename]:start()
    else
      lintplus_delays[filename]:reset()
      lintplus_delays[filename]:start()
    end
  end
end


return diagnostics