aboutsummaryrefslogtreecommitdiff
path: root/plugins/editorconfig/init.lua
diff options
context:
space:
mode:
authorJefferson González <jgmdev@gmail.com>2022-12-24 23:06:24 -0400
committerGitHub <noreply@github.com>2022-12-24 23:06:24 -0400
commit363c3eb859ddbe1efad5949ad087c8e7db6cee41 (patch)
treedf6ae14c1b360d710ce0d4b5c11950e91a2d0517 /plugins/editorconfig/init.lua
parent7bb53600a33c2f630f2a0850a543c356a856f172 (diff)
downloadlite-xl-plugins-363c3eb859ddbe1efad5949ad087c8e7db6cee41.tar.gz
lite-xl-plugins-363c3eb859ddbe1efad5949ad087c8e7db6cee41.zip
added editorconfig plugin (#163)
* properly ignore comments on declarations * strip newlines when insert_final_newline set to false * force match up to the end * support unset property * make property values case insensitive * handle indent_size when set to tab * handle escaped comment chars * also lowercase property names * support utf-8 as on spec * apply rules to unsaved unamed/named docs * annotation fix * added test suite and fixes for 100% pass rate * do not force match from start, breaks 2 tests * allow starting wild cards to match everything * disabled print buffering * make last line visible if insert_final_newline true * use log_quiet for debug * adapted to changes on lite-xl/lite-xl#1232 * properly return from add/remove_project_directory overrides * Use new trimwhitespace functionality if possible
Diffstat (limited to 'plugins/editorconfig/init.lua')
-rw-r--r--plugins/editorconfig/init.lua441
1 files changed, 441 insertions, 0 deletions
diff --git a/plugins/editorconfig/init.lua b/plugins/editorconfig/init.lua
new file mode 100644
index 0000000..a3df02c
--- /dev/null
+++ b/plugins/editorconfig/init.lua
@@ -0,0 +1,441 @@
+-- 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