Initial commit

This commit is contained in:
Patrick Alvin Alcala 2025-06-26 16:53:43 +08:00
commit 209ba130c0
4852 changed files with 1517959 additions and 0 deletions

View file

@ -0,0 +1,977 @@
-- 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