Newer
Older
dotfiles / .config / lite-xl / plugins / lintplus / init.lua
@Edoko Edoko 25 days ago 23 KB initial commit
-- mod-version:3

-- lint+ - an improved linter for lite
-- copyright (C) lqdev, 2020
-- licensed under the MIT license


--- STATIC CONFIG ---


local kind_priority = {
  info = -1,
  hint = 0,
  warning = 1,
  error = 2,
}

local default_kind_pretty_names = {
  info = "I",
  hint = "H",
  warning = "W",
  error = "E",
}


--- IMPLEMENTATION ---


local core = require "core"
local command = require "core.command"
local common = require "core.common"
local config = require "core.config"
local style = require "core.style"
local keymap = require "core.keymap"
local syntax = require "core.syntax"

local Doc = require "core.doc"
local DocView = require "core.docview"
local StatusView = require "core.statusview"

local liteipc = require "plugins.lintplus.liteipc"


local lint = {}
lint.fs = require "plugins.lintplus.fsutil"
lint.ipc = liteipc


lint.index = {}
lint.messages = {}


local LintContext = {}
LintContext.__index = LintContext


function LintContext:create_gutter_rail()
  if not self._doc then return 0 end
  local lp = self._doc.__lintplus
  lp.rail_count = lp.rail_count + 1
  return lp.rail_count
end


function LintContext:gutter_rail_count()
  if not self._doc then return 0 end
  return self._doc.__lintplus.rail_count
end


-- Can be used by other plugins to properly set the context when loading a doc
function lint.init_doc(filename, doc)
  filename = core.project_absolute_path(filename)
  local context = setmetatable({
    _doc = doc or nil,
    _user_context = nil,
  }, LintContext)

  if doc then
    doc.__lintplus_context = {}
    context._user_context = doc.__lintplus_context

    doc.__lintplus = {
      rail_count = 0,
    }
  end

  if not lint.messages[filename] then
    lint.messages[filename] = {
      context = context,
      lines = {},
      rails = {},
    }
  elseif doc then
    lint.messages[filename].context = context
  end
end


-- Returns an appropriate linter for the given doc, or nil if no linter is
-- found.
function lint.get_linter_for_doc(doc)
  if not doc.filename then
    return nil
  end

  local file = core.project_absolute_path(doc.filename)
  for name, linter in pairs(lint.index) do
    if common.match_pattern(file, linter.filename) then
      return linter, name
    end
    if linter.syntax ~= nil then
      local header = doc:get_text(1, 1, doc:position_offset(1, 1, 128))
      local syn = syntax.get(doc.filename, header)
      for i = #linter.syntax, 1, -1 do
        local s = linter.syntax[i]
        if syn.name == s then
          return linter, name
        end
      end
    end
  end
end


-- unused for now, because it was a bit buggy
-- Note: Should be fixed now
function lint.clear_messages(filename)
  filename = core.project_absolute_path(filename)

  if lint.messages[filename] then
    lint.messages[filename].lines = {}
    lint.messages[filename].rails = {}
  end
end


function lint.add_message(filename, line, column, kind, message, rail)
  filename = core.project_absolute_path(filename)
  if not lint.messages[filename] then
    -- This allows us to at least store messages until context is properly
    -- set from the calling plugin.
    lint.init_doc(filename)
  end
  local file_messages = lint.messages[filename]
  local lines, rails = file_messages.lines, file_messages.rails
  lines[line] = lines[line] or {}
  if rail ~= nil then
    rails[rail] = rails[rail] or { lines_taken = {} }
    if not rails[rail].lines_taken[line] then
      rails[rail].lines_taken[line] = true
      table.insert(rails[rail], {
        line = line,
        column = column,
        kind = kind,
      })
    end
  end
  table.insert(lines[line], {
    column = column,
    kind = kind,
    message = message,
    rail = rail,
  })
end


local function process_line(doc, linter, line, context)
  local file = core.project_absolute_path(doc.filename)

  local had_messages = false

  local iterator = linter.procedure.interpreter(file, line, context)
  if iterator == "bail" then return iterator end

  if os.getenv("LINTPLUS_DEBUG_LINES") then
    print("lint+ | "..line)
  end

  for rawfile, lineno, columnno, kind, message, rail in iterator do
    assert(type(rawfile) == "string")
    local absfile = core.project_absolute_path(rawfile)
    if absfile == file then -- TODO: support project-wide errors
      assert(type(lineno) == "number")
      assert(type(columnno) == "number")
      assert(type(kind) == "string")
      assert(type(message) == "string")
      assert(rail == nil or type(rail) == "number")

      lint.add_message(absfile, lineno, columnno, kind, message, rail)
      core.redraw = true
    end
  end

  return had_messages
end


local function compare_message_priorities(a, b)
  return kind_priority[a.kind] > kind_priority[b.kind]
end

local function compare_messages(a, b)
  if a.column == b.column then
    return compare_message_priorities(a, b)
  end
  return a.column > b.column
end

local function compare_rail_messages(a, b)
  return a.line < b.line
end


function lint.check(doc)
  if doc.filename == nil then return end

  local linter, linter_name = lint.get_linter_for_doc(doc)
  if linter == nil then
    core.error("no linter available for the given filetype")
    return
  end

  local filename = core.project_absolute_path(doc.filename)
  local context = setmetatable({
    _doc = doc,
    _user_context = doc.__lintplus_context,
  }, LintContext)

  doc.__lintplus = {
    rail_count = 0,
  }
--   clear_messages(linter)
  lint.messages[filename] = {
    context = context,
    lines = {},
    rails = {},
  }

  local function report_error(msg)
    core.log_quiet(
      "lint+/" .. linter_name .. ": " ..
      doc.filename .. ": " .. msg
    )
  end

  local cmd, cwd = linter.procedure.command(filename), nil
  if cmd.set_cwd then
    cwd = lint.fs.parent_directory(filename)
  end
  local process = liteipc.start_process(cmd, cwd)
  core.add_thread(function ()
    -- poll the process for lines of output
    while true do
      local exit, code, errmsg = process:poll(function (line)
        process_line(doc, linter, line, context)
      end)
      if exit ~= nil then
        -- If linter exited with exit code non 0 or 1 log it
        if exit == "signal" then
          report_error(
            "linter exited with signal " .. code
            .. (errmsg and " : " .. errmsg or "")
          )
        end
        break
      end
      coroutine.yield(0)
    end
    -- after reading some lines, sort messages by priority in all files
    -- and sort rail connections by line number
    for _, file_messages in pairs(lint.messages) do
      for _, messages in pairs(file_messages.lines) do
        table.sort(messages, compare_messages)
      end
      for _, rail in pairs(file_messages.rails) do
        table.sort(rail, compare_rail_messages)
      end
      file_messages.rails_sorted = true
      core.redraw = true
      coroutine.yield(0)
    end
  end)
end


-- inject initialization routines to documents

local Doc_load, Doc_save, Doc_on_close = Doc.load, Doc.save, Doc.on_close

local function init_linter_for_doc(doc)
  local linter, _ = lint.get_linter_for_doc(doc)
  if linter == nil then return end
  doc.__lintplus_context = {}
  if linter.procedure.init ~= nil then
    linter.procedure.init(
      core.project_absolute_path(doc.filename),
      doc.__lintplus_context
    )
  end
end

function Doc:load(filename)
  local old_filename = self.filename
  Doc_load(self, filename)
  if old_filename ~= filename then
    init_linter_for_doc(self)
  end
end

function Doc:save(filename, abs_filename)
  local old_filename = self.filename
  Doc_save(self, filename, abs_filename)
  if old_filename ~= filename then
    init_linter_for_doc(self)
  end
end

function Doc:on_close()
  Doc_on_close(self)
  if not self.filename then return end
  local filename = core.project_absolute_path(self.filename)
  -- release Doc object for proper garbage collection
  if lint.messages[filename] then
    lint.messages[filename] = nil
  end
end


-- inject hooks to Doc.insert and Doc.remove to shift messages around

local function sort_positions(line1, col1, line2, col2)
  if line1 > line2
  or line1 == line2 and col1 > col2 then
    return line2, col2, line1, col1, true
  end
  return line1, col1, line2, col2, false
end

local Doc_insert = Doc.insert
function Doc:insert(line, column, text)
  Doc_insert(self, line, column, text)

  if self.filename == nil then return end
  if line == math.huge then return end

  local filename = core.project_absolute_path(self.filename)
  local file_messages = lint.messages[filename]
  local lp = self.__lintplus
  if file_messages == nil or lp == nil then return end

  -- shift line messages downwards
  local shift = 0
  for _ in text:gmatch('\n') do
    shift = shift + 1
  end
  if shift == 0 then return end

  local lines = file_messages.lines
  for i = #self.lines, line, -1 do
    if lines[i] ~= nil then
      if not (i == line and lines[i][1].column < column) then
        lines[i + shift] = lines[i]
        lines[i] = nil
      end
    end
  end

  -- shift rails downwards
  local rails = file_messages.rails
  for _, rail in pairs(rails) do
    for _, message in ipairs(rail) do
      if message.line >= line then
        message.line = message.line + shift
      end
    end
  end
end

local function update_messages_after_removal(
  doc,
  line1, column1,
  line2, column2
)
  if line1 == line2 then return end
  if line2 == math.huge then return end
  if doc.filename == nil then return end

  local filename = core.project_absolute_path(doc.filename)
  local file_messages = lint.messages[filename]
  local lp = doc.__lintplus
  if file_messages == nil or lp == nil then return end

  local lines = file_messages.lines

  line1, column1, line2, column2 =
    sort_positions(line1, column1, line2, column2)
  local shift = line2 - line1

  -- remove all messages in this range
  for i = line1, line2 do
    lines[i] = nil
  end

  -- shift all line messages up
  for i = line1, #doc.lines do
    if lines[i] ~= nil then
      lines[i - shift] = lines[i]
      lines[i] = nil
    end
  end

  -- remove all rail messages in this range
  local rails = file_messages.rails
  for _, rail in pairs(rails) do
    local remove_indices = {}
    for i, message in ipairs(rail) do
      if message.line >= line1 and message.line < line2 then
        table.insert(remove_indices, i)
      elseif message.line > line1 then
        message.line = message.line - shift
      end
    end
    for i = #remove_indices, 1, -1 do
      table.remove(rail, remove_indices[i])
    end
  end
end

local Doc_remove = Doc.remove
function Doc:remove(line1, column1, line2, column2)
  update_messages_after_removal(self, line1, column1, line2, column2)
  Doc_remove(self, line1, column1, line2, column2)
end


-- inject rendering routines

local renderutil = require "plugins.lintplus.renderutil"

local function rail_width(dv)
  return dv:get_line_height() / 3 -- common.round(style.padding.x / 2)
end

local function rail_spacing(dv)
  return common.round(rail_width(dv) / 4)
end

local DocView_get_gutter_width = DocView.get_gutter_width
function DocView:get_gutter_width()
  local extra_width = 0
  if self.doc.filename ~= nil then
    local file_messages = lint.messages[core.project_absolute_path(self.doc.filename)]
    if file_messages ~= nil then
      local rail_count = file_messages.context:gutter_rail_count()
      extra_width = rail_count * (rail_width(self) + rail_spacing(self))
    end
  end
  local original_width, padding = DocView_get_gutter_width(self)
  return original_width + extra_width, padding
end


local function get_gutter_rail_x(dv, index)
  return
    dv.position.x + dv:get_gutter_width() -
    (rail_width(dv) + rail_spacing(dv)) * index + rail_spacing(dv)
end


local function get_message_group_color(messages)
  if style.lint ~= nil then
    return style.lint[messages[1].kind]
  else
    local default_colors = {
      info = style.syntax["normal"],
      hint = style.syntax["function"],
      warning = style.syntax["number"],
      error = style.syntax["keyword2"]
    }
    return default_colors[messages[1].kind]
  end
end

local function get_underline_y(dv, line)
  local _, y = dv:get_line_screen_position(line)
  local line_height = dv:get_line_height()
  local extra_space = line_height - dv:get_font():get_height()
  return y + line_height - extra_space / 2
end

local function draw_gutter_rail(dv, index, messages)
  local rail = messages.rails[index]
  if rail == nil or #rail < 2 then return end

  local first_message = rail[1]
  local last_message = rail[#rail]

  local x = get_gutter_rail_x(dv, index)
  local rw = rail_width(dv)
  local start_y = get_underline_y(dv, first_message.line)
  local fin_y = get_underline_y(dv, last_message.line)

  -- connect with lens
  local line_x = x + rw
  for i, message in ipairs(rail) do
    -- connect with lens
    local lx, _ = dv:get_line_screen_position(message.line)
    local ly = get_underline_y(dv, message.line)
    local line_messages = messages.lines[message.line]
    if line_messages ~= nil then
      local column = line_messages[1].column
      local message_left = line_messages[1].message:sub(1, column - 1)
      local line_color = get_message_group_color(line_messages)
      local xoffset = (x + rw) % 2
      local line_w = dv:get_font():get_width(message_left) - line_x + lx
      renderutil.draw_dotted_line(x + rw + xoffset, ly, line_w, 'x', line_color)
      -- draw curve
      ly = ly - rw * (i == 1 and 0 or 1) + (i ~= 1 and 1 or 0)
      renderutil.draw_quarter_circle(x, ly, rw, style.accent, i > 1)
    end
  end

  -- draw vertical part
  local height = fin_y - start_y + 1 - rw * 2
  renderer.draw_rect(x, start_y + rw, 1, height, style.accent)

end

local DocView_draw = DocView.draw
function DocView:draw()
  DocView_draw(self)

  local filename = self.doc.filename
  if filename == nil then return end
  filename = core.project_absolute_path(filename)
  local messages = lint.messages[filename]
  if messages == nil or not messages.rails_sorted then return end
  local rails = messages.rails

  local pos, size = self.position, self.size
  core.push_clip_rect(pos.x, pos.y, size.x, size.y)
  for i = 1, #rails do
    draw_gutter_rail(self, i, messages)
  end
  core.pop_clip_rect()
end


local lens_underlines = {

  blank = function () end,

  solid = function (x, y, width, color)
    renderer.draw_rect(x, y, width, 1, color)
  end,

  dots = function (x, y, width, color)
    renderutil.draw_dotted_line(x, y, width, 'x', color)
  end,

}

local function draw_lens_underline(x, y, width, color)
  local lens_style = config.lint.lens_style or "solid"
  if type(lens_style) == "string" then
    local fn = lens_underlines[lens_style] or lens_underlines.blank
    fn(x, y, width, color)
  elseif type(lens_style) == "function" then
    lens_style(x, y, width, color)
  end
end

local function get_or_default(t, index, default)
  if t ~= nil and t[index] ~= nil then
    return t[index]
  else
    return default
  end
end

local DocView_draw_line_text = DocView.draw_line_text
function DocView:draw_line_text(idx, x, y)
  DocView_draw_line_text(self, idx, x, y)

  local lp = self.doc.__lintplus
  if lp == nil then return end

  local yy = get_underline_y(self, idx)
  local file_messages = lint.messages[core.project_absolute_path(self.doc.filename)]
  if file_messages == nil then return end
  local messages = file_messages.lines[idx]
  if messages == nil then return end

  local underline_start = messages[1].column

  local font = self:get_font()
  local underline_color = get_message_group_color(messages)
  local line = self.doc.lines[idx]
  local line_left = line:sub(1, underline_start - 1)
  local line_right = line:sub(underline_start, -2)
  local underline_x = font:get_width(line_left)
  local w = font:get_width('w')

  local msg_x = x + w * 3 + underline_x + font:get_width(line_right)
  local text_y = y + self:get_line_text_y_offset()
  for i, msg in ipairs(messages) do
    local text_color = get_or_default(style.lint, msg.kind, underline_color)
    msg_x = renderer.draw_text(font, msg.message, msg_x, text_y, text_color)
    if i < #messages then
      msg_x = renderer.draw_text(font, ",  ", msg_x, text_y, style.syntax.comment)
    end
  end

  local underline_width = msg_x - x - underline_x
  draw_lens_underline(x + underline_x, yy, underline_width, underline_color)
end


local function table_add(t, d)
  for _, v in ipairs(d) do
    table.insert(t, v)
  end
end


local function kind_pretty_name(kind)
  return (config.kind_pretty_names or default_kind_pretty_names)[kind]
end


local function get_error_messages(doc, ordered)
  if not doc then return nil end
  local messages = lint.messages[core.project_absolute_path(doc.filename)]
  if not messages then return nil end
  if not ordered then return messages.lines end
  -- sort lines
  local lines = {}
  for line, _ in pairs(messages.lines) do
    table.insert(lines, line)
  end
  table.sort(lines, function(a, b) return a < b end)
  local lines_info = {}
  -- store in array instead of dictionary to keep insertion order
  for _, line in ipairs(lines) do
    table.insert(
      lines_info,
      {line = line, table.unpack(messages.lines[line])}
    )
  end
  return lines_info
end


local function get_current_error(doc)
  local file_messages = get_error_messages(doc)
  local line, message = math.huge, nil
  for ln, messages in pairs(file_messages) do
    local msg = messages[1]
    if msg.kind == "error" and ln < line  then
      line, message = ln, msg
    end
  end
  if message ~= nil then
    return line, message.kind, message.message
  end
  return nil, nil, nil
end


local function goto_prev_message()
  local doc = core.active_view.doc
  local current_line = doc:get_selection()
  local file_messages = get_error_messages(doc, true)
  if file_messages ~= nil then
    local prev = nil
    local found = false
    local last = nil
    for _, line_info in pairs(file_messages) do
      local line = line_info.line
      if current_line <= line  then
        found = true
      end
      if not found then
        prev = line
      end
      last = line
    end
    local line = prev or last
    if line then
      doc:set_selection(line, 1, line, 1)
    end
  end
end


local function goto_next_message()
  local doc = core.active_view.doc
  local current_line = doc:get_selection()
  local file_messages = get_error_messages(doc, true)
  if file_messages ~= nil then
    local first = nil
    local next = nil
    for _, line_info in pairs(file_messages) do
      local line = line_info.line
      if not first then
        first = line
      end
      if line > current_line then
        next = line
        break
      end
    end
    local line = next or first
    if line then
      doc:set_selection(line, 1, line, 1)
    end
  end
end


local function get_status_view_items()
  local doc = core.active_view.doc
  local line1, _, line2, _ = doc:get_selection()
  local file_messages = get_error_messages(doc)
  if file_messages ~= nil then
    if file_messages[line1] ~= nil and line1 == line2 then
      local msg = file_messages[line1][1]
      return {
        kind_pretty_name(msg.kind), ": ",
        style.text, msg.message,
      }
    else
      local line, kind, message = get_current_error(doc)
      if line ~= nil then
        return {
          "line ", tostring(line), " ", kind_pretty_name(kind), ": ",
          style.text, message,
        }
      end
    end
  end
  return {}
end

if StatusView["add_item"] then
  core.status_view:add_item({
    predicate = function()
      local doc = core.active_view.doc
      if
        doc and doc.filename  -- skip new files
        and
        getmetatable(core.active_view) == DocView
        and
        (
          lint.get_linter_for_doc(doc)
          or
          lint.messages[core.project_absolute_path(doc.filename)]
        )
      then
        return true
      end
      return false
    end,
    name = "lint+:message",
    alignment = StatusView.Item.LEFT,
    get_item = get_status_view_items,
    command = function()
      local doc = core.active_view.doc
      local line = get_current_error(doc)
      if line ~= nil then
        doc:set_selection(line, 1, line, 1)
      end
    end,
    position = -1,
    tooltip = "Lint+ error message",
    separator = core.status_view.separator2
  })
else
  local StatusView_get_items = StatusView.get_items
  function StatusView:get_items()
    local left, right = StatusView_get_items(self)
    local doc = core.active_view.doc

    if
      doc and doc.filename  -- skip new files
      and
      getmetatable(core.active_view) == DocView
      and
      (
        lint.get_linter_for_doc(doc)
        or
        lint.messages[core.project_absolute_path(doc.filename)]
      )
    then
      local items = get_status_view_items()
      if #items > 0 then
        table.insert(left, {style.dim, self.separator2, table.unpack(items)})
      end
    end

    return left, right
  end
end


command.add(DocView, {
  ["lint+:check"] = function ()
    lint.check(core.active_view.doc)
  end
})

command.add(DocView, {
  ["lint+:goto-previous-message"] = function ()
    goto_prev_message()
  end
})

command.add(DocView, {
  ["lint+:goto-next-message"] = function ()
    goto_next_message()
  end
})

keymap.add {
  ["alt+up"]    = "lint+:goto-previous-message",
  ["alt+down"]  = "lint+:goto-next-message"
}


--- LINTER PLUGINS ---


function lint.add(name)
  return function (linter)
    lint.index[name] = linter
  end
end


--- SETUP ---


lint.setup = {}

function lint.setup.lint_on_doc_load()

  local doc_load = Doc.load
  function Doc:load(filename)
    doc_load(self, filename)
    if not self.filename then return end
    if lint.get_linter_for_doc(self) ~= nil then
      lint.check(self)
    end
  end

end

function lint.setup.lint_on_doc_save()

  local doc_save = Doc.save
  function Doc:save(filename, abs_filename)
    doc_save(self, filename, abs_filename)
    if lint.get_linter_for_doc(self) ~= nil then
      lint.check(self)
    end
  end

end

function lint.enable_async()
  core.error("lint+: calling enable_async() is not needed anymore")
end


--- LINTER CREATION UTILITIES ---


lint.filename = {}
lint.args = {}


local function map(tab, fn)
  local result = {}
  for k, v in pairs(tab) do
    local mapped, mode = fn(k, v)
    if mode == "append" then
      table_add(result, mapped)
    elseif type(k) == "number" then
      table.insert(result, mapped)
    else
      result[k] = mapped
    end
  end
  return result
end


function lint.command(cmd)
  return function (filename)
    return map(cmd, function (k, v)
      if type(k) == "number" and v == lint.filename then
        return filename
      end
      return v
    end)
  end
end


function lint.args_command(cmd, config_option)
  return function (filename)
    local c = map(cmd, function (k, v)
      if type(k) == "number" and v == lint.args then
        local args = lint.config[config_option] or {}
        return args, "append"
      end
      return v
    end)
    return lint.command(c)(filename)
  end
end


function lint.interpreter(i)
  local patterns = {
    info = i.info,
    hint = i.hint,
    warning = i.warning,
    error = i.error,
  }
  local strip_pattern = i.strip

  return function (_, line)
    local line_processed = false
    return function ()
      if line_processed then
        return nil
      end
      for kind, patt in pairs(patterns) do
        assert(
          type(patt) == "string",
          "lint+: interpreter pattern must be a string")
        local file, ln, column, message = line:match(patt)
        if file then
          if strip_pattern then
            message = message:gsub(strip_pattern, "")
          end
          line_processed = true
          return file, tonumber(ln), tonumber(column), kind, message
        end
      end
    end
  end
end


function lint.load(linter)
  if type(linter) == "table" then
    for _, v in ipairs(linter) do
      require("plugins.lintplus.linters." .. v)
    end
  elseif type(linter) == "string" then
    require("plugins.lintplus.linters." .. linter)
  end
end


if type(config.lint) ~= "table" then
  config.lint = {}
end
lint.config = config.lint


--- END ---

return lint