442 lines
13 KiB
Lua
442 lines
13 KiB
Lua
|
-- mod-version:3
|
||
|
--
|
||
|
-- EditorConfig plugin for Lite XL
|
||
|
-- @copyright Jefferson Gonzalez <jgmdev@gmail.com>
|
||
|
-- @license MIT
|
||
|
--
|
||
|
-- Note: this plugin needs to be loaded after detectindent plugin,
|
||
|
-- since the name editorconfig.lua is ordered after detectindent.lua
|
||
|
-- there shouldn't be any issues. Just a reminder for the future in
|
||
|
-- case of a plugin that could also handle document identation type
|
||
|
-- and size, and has a name with more weight than this plugin.
|
||
|
--
|
||
|
local core = require "core"
|
||
|
local common = require "core.common"
|
||
|
local config = require "core.config"
|
||
|
local trimwhitespace = require "plugins.trimwhitespace"
|
||
|
local Doc = require "core.doc"
|
||
|
local Parser = require "plugins.editorconfig.parser"
|
||
|
|
||
|
---@class config.plugins.editorconfig
|
||
|
---@field debug boolean
|
||
|
config.plugins.editorconfig = common.merge({
|
||
|
debug = false,
|
||
|
-- The config specification used by the settings gui
|
||
|
config_spec = {
|
||
|
name = "EditorConfig",
|
||
|
{
|
||
|
label = "Debug",
|
||
|
description = "Display debugging messages on the log.",
|
||
|
path = "debug",
|
||
|
type = "toggle",
|
||
|
default = false
|
||
|
}
|
||
|
}
|
||
|
}, config.plugins.editorconfig)
|
||
|
|
||
|
---Cache of .editorconfig options to reduce parsing for every opened file.
|
||
|
---@type table<string, plugins.editorconfig.parser>
|
||
|
local project_configs = {}
|
||
|
|
||
|
---Keep track of main project directory so when changed we can assign a new
|
||
|
---.editorconfig object if neccesary.
|
||
|
---@type string
|
||
|
local main_project = core.project_dir
|
||
|
|
||
|
---Functionality that will be exposed by the plugin.
|
||
|
---@class plugins.editorconfig
|
||
|
local editorconfig = {}
|
||
|
|
||
|
---Load global .editorconfig options for a project.
|
||
|
---@param project_dir string
|
||
|
---@return boolean loaded
|
||
|
function editorconfig.load(project_dir)
|
||
|
local editor_config = project_dir .. "/" .. ".editorconfig"
|
||
|
local file = io.open(editor_config)
|
||
|
if file then
|
||
|
file:close()
|
||
|
project_configs[project_dir] = Parser.new(editor_config)
|
||
|
return true
|
||
|
end
|
||
|
return false
|
||
|
end
|
||
|
|
||
|
---Helper to add or substract final new line, it also makes final new line
|
||
|
---visble which lite-xl does not.
|
||
|
---@param doc core.doc
|
||
|
---@param raw? boolean If true does not register change on undo stack
|
||
|
---@return boolean handled_new_line
|
||
|
local function handle_final_new_line(doc, raw)
|
||
|
local handled = false
|
||
|
---@diagnostic disable-next-line
|
||
|
if doc.insert_final_newline then
|
||
|
handled = true
|
||
|
if doc.lines[#doc.lines] ~= "\n" then
|
||
|
if not raw then
|
||
|
doc:insert(#doc.lines, math.huge, "\n")
|
||
|
else
|
||
|
table.insert(doc.lines, "\n")
|
||
|
end
|
||
|
end
|
||
|
---@diagnostic disable-next-line
|
||
|
elseif type(doc.insert_final_newline) == "boolean" then
|
||
|
handled = true
|
||
|
if trimwhitespace.trim_empty_end_lines then
|
||
|
trimwhitespace.trim_empty_end_lines(doc, raw)
|
||
|
-- TODO: remove this once 2.1.1 is released
|
||
|
else
|
||
|
for _=#doc.lines, 1, -1 do
|
||
|
local l = #doc.lines
|
||
|
if l > 1 and doc.lines[l] == "\n" then
|
||
|
local current_line = doc:get_selection()
|
||
|
if current_line == l then
|
||
|
doc:set_selection(l-1, math.huge, l-1, math.huge)
|
||
|
end
|
||
|
if not raw then
|
||
|
doc:remove(l-1, math.huge, l, math.huge)
|
||
|
else
|
||
|
table.remove(doc.lines, l)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
return handled
|
||
|
end
|
||
|
|
||
|
---Split the given relative path by / or \ separators.
|
||
|
---@param path string The path to split
|
||
|
---@return table
|
||
|
local function split_path(path)
|
||
|
local result = {};
|
||
|
for match in (path.."/"):gmatch("(.-)".."[:\\/]") do
|
||
|
table.insert(result, match);
|
||
|
end
|
||
|
return result;
|
||
|
end
|
||
|
|
||
|
---Check if the given file path exists.
|
||
|
---@param file_path string
|
||
|
local function file_exists(file_path)
|
||
|
local file = io.open(file_path, "r")
|
||
|
if not file then return false end
|
||
|
file:close()
|
||
|
return true
|
||
|
end
|
||
|
|
||
|
---Merge a config options to target if they don't already exists on target.
|
||
|
---@param config_target? plugins.editorconfig.parser.section
|
||
|
---@param config_from? plugins.editorconfig.parser.section
|
||
|
local function merge_config(config_target, config_from)
|
||
|
if config_target and config_from then
|
||
|
for name, value in pairs(config_from) do
|
||
|
if type(config_target[name]) == "nil" then
|
||
|
config_target[name] = value
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
---Scan for .editorconfig files from current file path to upper project path
|
||
|
---if root attribute is not found first and returns matching config.
|
||
|
---@param file_path string
|
||
|
---@return plugins.editorconfig.parser.section?
|
||
|
local function recursive_get_config(file_path)
|
||
|
local project_dir = ""
|
||
|
|
||
|
local root_config
|
||
|
for path, editor_config in pairs(project_configs) do
|
||
|
if common.path_belongs_to(file_path, path) then
|
||
|
project_dir = path
|
||
|
root_config = editor_config:getConfig(
|
||
|
common.relative_path(path, file_path)
|
||
|
)
|
||
|
break
|
||
|
end
|
||
|
end
|
||
|
|
||
|
if project_dir == "" then
|
||
|
for _, project in ipairs(core.project_directories) do
|
||
|
if common.path_belongs_to(file_path, project.name) then
|
||
|
project_dir = project.name
|
||
|
break
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
local relative_file_path = common.relative_path(project_dir, file_path)
|
||
|
local dir = common.dirname(relative_file_path)
|
||
|
|
||
|
local editor_config = {}
|
||
|
local config_found = false
|
||
|
if not dir and root_config then
|
||
|
editor_config = root_config
|
||
|
config_found = true
|
||
|
elseif dir then
|
||
|
local path_list = split_path(dir)
|
||
|
local root_found = false
|
||
|
for p=#path_list, 1, -1 do
|
||
|
local path = project_dir .. "/" .. table.concat(path_list, "/", 1, p)
|
||
|
if file_exists(path .. "/" .. ".editorconfig") then
|
||
|
---@type plugins.editorconfig.parser
|
||
|
local parser = Parser.new(path .. "/" .. ".editorconfig")
|
||
|
local pconfig = parser:getConfig(common.relative_path(path, file_path))
|
||
|
if pconfig then
|
||
|
merge_config(editor_config, pconfig)
|
||
|
config_found = true
|
||
|
end
|
||
|
if parser.root then
|
||
|
root_found = true
|
||
|
break
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
if not root_found and root_config then
|
||
|
merge_config(editor_config, root_config)
|
||
|
config_found = true
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- clean unset options
|
||
|
if config_found then
|
||
|
local all_unset = true
|
||
|
for name, value in pairs(editor_config) do
|
||
|
if value == "unset" then
|
||
|
editor_config[name] = nil
|
||
|
else
|
||
|
all_unset = false
|
||
|
end
|
||
|
end
|
||
|
if all_unset then config_found = false end
|
||
|
end
|
||
|
|
||
|
return config_found and editor_config or nil
|
||
|
end
|
||
|
|
||
|
---Apply editorconfig rules to given doc if possible.
|
||
|
---@param doc core.doc
|
||
|
function editorconfig.apply(doc)
|
||
|
if not doc.abs_filename and not doc.filename then return end
|
||
|
local file_path = doc.abs_filename or (main_project .. "/" .. doc.filename)
|
||
|
local options = recursive_get_config(file_path)
|
||
|
if options then
|
||
|
if config.plugins.editorconfig.debug then
|
||
|
core.log_quiet(
|
||
|
"[EditorConfig]: %s applied %s",
|
||
|
file_path, common.serialize(options, {pretty = true})
|
||
|
)
|
||
|
end
|
||
|
local indent_type, indent_size = doc:get_indent_info()
|
||
|
if options.indent_style then
|
||
|
if options.indent_style == "tab" then
|
||
|
indent_type = "hard"
|
||
|
else
|
||
|
indent_type = "soft"
|
||
|
end
|
||
|
end
|
||
|
|
||
|
if options.indent_size and options.indent_size == "tab" then
|
||
|
if options.tab_width then
|
||
|
options.indent_size = options.tab_width
|
||
|
else
|
||
|
options.indent_size = config.indent_size or 2
|
||
|
end
|
||
|
end
|
||
|
|
||
|
if options.indent_size then
|
||
|
indent_size = options.indent_size
|
||
|
end
|
||
|
|
||
|
if doc.indent_info then
|
||
|
doc.indent_info.type = indent_type
|
||
|
doc.indent_info.size = indent_size
|
||
|
doc.indent_info.confirmed = true
|
||
|
else
|
||
|
doc.indent_info = {
|
||
|
type = indent_type,
|
||
|
size = indent_size,
|
||
|
confirmed = true
|
||
|
}
|
||
|
end
|
||
|
|
||
|
if options.end_of_line then
|
||
|
if options.end_of_line == "crlf" then
|
||
|
doc.crlf = true
|
||
|
elseif options.end_of_line == "lf" then
|
||
|
doc.crlf = false
|
||
|
end
|
||
|
end
|
||
|
|
||
|
if options.trim_trailing_whitespace then
|
||
|
doc.trim_trailing_whitespace = true
|
||
|
elseif options.trim_trailing_whitespace == false then
|
||
|
doc.trim_trailing_whitespace = false
|
||
|
else
|
||
|
doc.trim_trailing_whitespace = nil
|
||
|
end
|
||
|
|
||
|
if options.insert_final_newline then
|
||
|
doc.insert_final_newline = true
|
||
|
elseif options.insert_final_newline == false then
|
||
|
doc.insert_final_newline = false
|
||
|
else
|
||
|
doc.insert_final_newline = nil
|
||
|
end
|
||
|
|
||
|
if
|
||
|
(
|
||
|
type(doc.trim_trailing_whitespace) == "boolean"
|
||
|
or
|
||
|
type(doc.insert_final_newline) == "boolean"
|
||
|
)
|
||
|
-- TODO: remove this once 2.1.1 is released
|
||
|
and
|
||
|
trimwhitespace.disable
|
||
|
then
|
||
|
trimwhitespace.disable(doc)
|
||
|
end
|
||
|
|
||
|
handle_final_new_line(doc, true)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
---Applies .editorconfig options to all open documents if possible.
|
||
|
function editorconfig.apply_all()
|
||
|
for _, doc in ipairs(core.docs) do
|
||
|
editorconfig.apply(doc)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- Load .editorconfig on all projects loaded at startup and apply it
|
||
|
--------------------------------------------------------------------------------
|
||
|
core.add_thread(function()
|
||
|
local loaded = false
|
||
|
|
||
|
-- scan all opened project directories
|
||
|
if core.project_directories then
|
||
|
for i=1, #core.project_directories do
|
||
|
local found = editorconfig.load(core.project_directories[i].name)
|
||
|
if found then loaded = true end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
-- if an editorconfig was found then try to apply it to opened docs
|
||
|
if loaded then
|
||
|
editorconfig.apply_all()
|
||
|
end
|
||
|
end)
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- Override various core project loading functions for .editorconfig scanning
|
||
|
--------------------------------------------------------------------------------
|
||
|
local core_open_folder_project = core.open_folder_project
|
||
|
function core.open_folder_project(directory)
|
||
|
core_open_folder_project(directory)
|
||
|
if project_configs[main_project] then project_configs[main_project] = nil end
|
||
|
main_project = core.project_dir
|
||
|
editorconfig.load(main_project)
|
||
|
end
|
||
|
|
||
|
local core_remove_project_directory = core.remove_project_directory
|
||
|
function core.remove_project_directory(path)
|
||
|
local out = core_remove_project_directory(path)
|
||
|
if project_configs[path] then project_configs[path] = nil end
|
||
|
return out
|
||
|
end
|
||
|
|
||
|
local core_add_project_directory = core.add_project_directory
|
||
|
function core.add_project_directory(directory)
|
||
|
local out = core_add_project_directory(directory)
|
||
|
editorconfig.load(directory)
|
||
|
return out
|
||
|
end
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- Hook into the core.doc to apply editor config options
|
||
|
--------------------------------------------------------------------------------
|
||
|
local doc_new = Doc.new
|
||
|
function Doc:new(...)
|
||
|
doc_new(self, ...)
|
||
|
editorconfig.apply(self)
|
||
|
end
|
||
|
|
||
|
---Cloned trimwitespace plugin until it is exposed for other plugins.
|
||
|
---@param doc core.doc
|
||
|
local function trim_trailing_whitespace(doc)
|
||
|
if trimwhitespace.trim then
|
||
|
trimwhitespace.trim(doc)
|
||
|
return
|
||
|
end
|
||
|
|
||
|
-- TODO: remove this once 2.1.1 is released
|
||
|
local cline, ccol = doc:get_selection()
|
||
|
for i = 1, #doc.lines do
|
||
|
local old_text = doc:get_text(i, 1, i, math.huge)
|
||
|
local new_text = old_text:gsub("%s*$", "")
|
||
|
|
||
|
-- don't remove whitespace which would cause the caret to reposition
|
||
|
if cline == i and ccol > #new_text then
|
||
|
new_text = old_text:sub(1, ccol - 1)
|
||
|
end
|
||
|
|
||
|
if old_text ~= new_text then
|
||
|
doc:insert(i, 1, new_text)
|
||
|
doc:remove(i, #new_text + 1, i, math.huge)
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
|
||
|
local doc_save = Doc.save
|
||
|
function Doc:save(...)
|
||
|
local new_file = self.new_file
|
||
|
|
||
|
---@diagnostic disable-next-line
|
||
|
if self.trim_trailing_whitespace then
|
||
|
trim_trailing_whitespace(self)
|
||
|
end
|
||
|
|
||
|
local lc = #self.lines
|
||
|
local handle_new_line = handle_final_new_line(self)
|
||
|
|
||
|
-- remove the unnecesary visible \n\n or the disabled \n
|
||
|
if handle_new_line then
|
||
|
self.lines[lc] = self.lines[lc]:gsub("\n$", "")
|
||
|
end
|
||
|
|
||
|
doc_save(self, ...)
|
||
|
|
||
|
-- restore the visible \n\n or disabled \n
|
||
|
if handle_new_line then
|
||
|
self.lines[lc] = self.lines[lc] .. "\n"
|
||
|
end
|
||
|
|
||
|
if common.basename(self.abs_filename) == ".editorconfig" then
|
||
|
-- blindlessly reload related project .editorconfig options
|
||
|
for _, project in ipairs(core.project_directories) do
|
||
|
if common.path_belongs_to(self.abs_filename, project.name) then
|
||
|
editorconfig.load(project.name)
|
||
|
break
|
||
|
end
|
||
|
end
|
||
|
-- re-apply editorconfig options to all open files
|
||
|
editorconfig.apply_all()
|
||
|
elseif new_file then
|
||
|
-- apply editorconfig options for file that was previously unsaved
|
||
|
editorconfig.apply(self)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
--------------------------------------------------------------------------------
|
||
|
-- Run the test suite if requested on CLI with: lite-xl test editorconfig
|
||
|
--------------------------------------------------------------------------------
|
||
|
for i, argument in ipairs(ARGS) do
|
||
|
if argument == "test" and ARGS[i+1] == "editorconfig" then
|
||
|
require "plugins.editorconfig.runtest"
|
||
|
os.exit()
|
||
|
end
|
||
|
end
|
||
|
|
||
|
|
||
|
return editorconfig
|