From bd591d47f212b7772e5d6e079f60cc0626fefa39 Mon Sep 17 00:00:00 2001 From: w0rp Date: Fri, 21 Mar 2025 23:37:35 +0000 Subject: [PATCH] Add basic Lua ALE functions and test coverage Ensure that basic ALE functions `ale.var`, `ale.escape`, and `ale.env` are available in Lua. Cover all Lua code so far with busted tests, fixing bugs where ALE variables can be set with Boolean values instead of numbers. Document all functionality so far. --- .github/workflows/main.yml | 1 + .luarc.json | 19 +++ Dockerfile | 13 +- autoload/ale/engine.vim | 2 +- doc/ale.txt | 29 +++- lua/ale/diagnostics.lua | 165 ++++++++++---------- lua/ale/init.lua | 88 +++++++++-- lua/ale/lsp.lua | 4 +- run-tests | 26 ++++ test/lua/.luarc.json | 21 +++ test/lua/ale_diagnostics_spec.lua | 232 +++++++++++++++++++++++++++++ test/lua/ale_env_spec.lua | 56 +++++++ test/lua/ale_lsp_spec.lua | 224 ++++++++++++++++++++++++++++ test/lua/ale_var_spec.lua | 51 +++++++ test/lua/windows_escaping_spec.lua | 61 ++++++++ test/script/run-lua-tests | 57 +++++++ 16 files changed, 944 insertions(+), 105 deletions(-) create mode 100644 .luarc.json create mode 100644 test/lua/ale_diagnostics_spec.lua create mode 100644 test/lua/ale_env_spec.lua create mode 100644 test/lua/ale_lsp_spec.lua create mode 100644 test/lua/ale_var_spec.lua create mode 100644 test/lua/windows_escaping_spec.lua create mode 100755 test/script/run-lua-tests diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bb7f1667..b2a14d09 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,7 @@ jobs: - '--vim-90-only' - '--neovim-07-only' - '--neovim-08-only' + - '--lua-only' - '--linters-only' steps: - uses: actions/checkout@v4 diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 00000000..b7a80308 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "diagnostics.globals": [ + "vim" + ], + "workspace.ignoreDir": [ + "test" + ], + "workspace.library": [ + "/usr/share/nvim/runtime/lua" + ], + "runtime.pathStrict": true, + "runtime.path": [ + "lua/?.lua", + "lua/?/init.lua" + ], + "runtime.version": "LuaJIT", + "hint.enable": false +} diff --git a/Dockerfile b/Dockerfile index ed81e93d..b47fa9dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,10 @@ ARG TESTBED_VIM_VERSION=24 FROM testbed/vim:${TESTBED_VIM_VERSION} -RUN install_vim -tag v8.0.0027 -build \ - -tag v9.0.0297 -build \ - -tag neovim:v0.7.0 -build \ - -tag neovim:v0.8.0 -build - ENV PACKAGES="\ + lua5.1 \ + lua5.1-dev \ + lua5.1-busted \ bash \ git \ python2 \ @@ -19,6 +17,11 @@ ENV PACKAGES="\ RUN apk --update add $PACKAGES && \ rm -rf /var/cache/apk/* /tmp/* /var/tmp/* +RUN install_vim -tag v8.0.0027 -build \ + -tag v9.0.0297 -build \ + -tag neovim:v0.7.0 -build \ + -tag neovim:v0.8.0 -build + RUN pip install vim-vint==0.3.21 RUN git clone https://github.com/junegunn/vader.vim vader && \ diff --git a/autoload/ale/engine.vim b/autoload/ale/engine.vim index c96091e1..b19b2761 100644 --- a/autoload/ale/engine.vim +++ b/autoload/ale/engine.vim @@ -259,7 +259,7 @@ function! ale#engine#SendResultsToNeovimDiagnostics(buffer, loclist) abort " Keep the Lua surface area really small in the VimL part of ALE, " and just require the diagnostics.lua module on demand. - let l:SendDiagnostics = luaeval('require("ale.diagnostics").sendAleResultsToDiagnostics') + let l:SendDiagnostics = luaeval('require("ale.diagnostics").send') call l:SendDiagnostics(a:buffer, a:loclist) endfunction diff --git a/doc/ale.txt b/doc/ale.txt index 772ed989..d3bf8224 100644 --- a/doc/ale.txt +++ b/doc/ale.txt @@ -4453,6 +4453,7 @@ Vim autocmd names `ale#Foo` are available in the Vim context, and functions documented with dot names `ale.foo` are available in Lua scripts. +ale.env(variable_name, value) *ale.env()* ale#Env(variable_name, value) *ale#Env()* Given a variable name and a string value, produce a string for including in @@ -4464,6 +4465,18 @@ ale#Env(variable_name, value) *ale#Env()* 'set VAR="some value" && command' # On Windows +ale.escape(str) *ale.escape()* +ale#Escape(str) *ale#Escape()* + + Given a string, escape that string so it is ready for shell execution. + + If the shell is detected to be `cmd.exe`, ALE will apply its own escaping + that tries to avoid escaping strings unless absolutely necessary to avoid + issues with Windows programs that do not properly handle quoted arguments. + + In all other cases, ALE will call |shellescape|. + + ale#GetFilenameMappings(buffer, name) *ale#GetFilenameMappings()* Given a `buffer` and the `name` of either a linter for fixer, return a @@ -4509,7 +4522,7 @@ ale#Queue(delay, [linting_flag, buffer_number]) *ale#Queue()* is broken, or when developing ALE itself. -ale.setup(config) *ale.setup* +ale.setup(config) *ale.setup()* Configure ALE global settings, which are documented in |ale-options|. For example: > @@ -4524,7 +4537,7 @@ ale.setup(config) *ale.setup* ALE is being configured in less ambiguous if you like. -ale.setup.buffer(config) *ale.setup.buffer* +ale.setup.buffer(config) *ale.setup.buffer()* Configure ALE buffer-local settings, which are documented in |ale-options|. For example: > @@ -4534,6 +4547,18 @@ ale.setup.buffer(config) *ale.setup.buffer* }) < +ale.var(buffer, variable_name) *ale.var()* +ale#Var(buffer, variable_name) *ale#Var()* + + Given a buffer number and an ALE variable name return the value of that + if defined in the buffer, and if not defined in the buffer return the + global value. The `ale_` prefix will be added to the Vim variable name. + + The `ale#Var` Vim function will return errors if the variable is not defined + in either the buffer or globally. The `ale.var` Lua function will return + `nil` if the variable is not defined in either the buffer or globally. + + ale#command#CreateDirectory(buffer) *ale#command#CreateDirectory()* Create a new temporary directory with a unique name, and manage that diff --git a/lua/ale/diagnostics.lua b/lua/ale/diagnostics.lua index 93f94859..77e2491f 100644 --- a/lua/ale/diagnostics.lua +++ b/lua/ale/diagnostics.lua @@ -1,96 +1,95 @@ +local ale = require("ale") + local module = {} -local ale_type_to_diagnostic_severity = { - E = vim.diagnostic.severity.ERROR, - W = vim.diagnostic.severity.WARN, - I = vim.diagnostic.severity.INFO +local diagnostic_severity_map = { + E = vim.diagnostic.severity.ERROR, + W = vim.diagnostic.severity.WARN, + I = vim.diagnostic.severity.INFO } --- Equivalent to ale#Var, only we can't error on missing global keys. -module.aleVar = function(buffer, key) - key = "ale_" .. key - local exists, value = pcall(vim.api.nvim_buf_get_var, buffer, key) - - if exists then - return value - end - - return vim.g[key] -end - -module.sendAleResultsToDiagnostics = function(buffer, loclist) - local diagnostics = {} - - -- Convert all the ALE loclist items to the shape that Neovim's diagnostic - -- API is expecting. - for _, location in ipairs(loclist) do - if location.bufnr == buffer then - table.insert( - diagnostics, - -- All line numbers from ALE are 1-indexed, but all line numbers - -- in the diagnostics API are 0-indexed, so we have to subtract 1 - -- to make this work. - { - lnum = location.lnum - 1, - -- Ending line number, or if we don't have one, just make it the same - -- as the starting line number - end_lnum = (location.end_lnum or location.lnum) - 1, - -- Which column does the error start on? - col = math.max((location.col or 1) - 1, 0), - -- end_col does *not* appear to need 1 subtracted, so we don't. - end_col = location.end_col, - -- Which severity: error, warning, or info? - severity = ale_type_to_diagnostic_severity[location.type] or "E", - -- An error code - code = location.code, - -- The error message - message = location.text, - -- e.g. "rubocop" - source = location.linter_name, - } - ) - end - end - - local virtualtext_enabled_set = { - ['all'] = true, - ['2'] = true, +-- A map of all possible values that we can consider virtualtext enabled for +-- from ALE's setting. +local virtualtext_enabled_set = { + ["all"] = true, + ["2"] = true, [2] = true, - ['current'] = true, - ['1'] = true, + ["current"] = true, + ["1"] = true, [1] = true, - } + [true] = true, +} - local set_signs = module.aleVar(buffer, 'set_signs') - local sign_priority = module.aleVar(buffer, 'sign_priority') - local signs +---Send diagnostics to the Neovim diagnostics API +---@param buffer number The buffer number to retreive the variable for. +---@param loclist table The loclist array to report as diagnostics. +---@return nil +module.send = function(buffer, loclist) + local diagnostics = {} - if set_signs == 1 and sign_priority then - -- If signs are enabled, set the priority for them. - local local_cfg = { priority = sign_priority } - local global_cfg = vim.diagnostic.config().signs - - if type(global_cfg) == 'boolean' then - signs = local_cfg - elseif type(global_cfg) == 'table' then - signs = vim.tbl_extend('force', global_cfg, local_cfg) - else - signs = function(...) - local calculated = global_cfg(...) - return vim.tbl_extend('force', calculated, local_cfg) - end + -- Convert all the ALE loclist items to the shape that Neovim's diagnostic + -- API is expecting. + for _, location in ipairs(loclist) do + if location.bufnr == buffer then + table.insert( + diagnostics, + -- All line numbers from ALE are 1-indexed, but all line + -- numbers in the diagnostics API are 0-indexed, so we have to + -- subtract 1 to make this work. + { + lnum = location.lnum - 1, + -- Ending line number, or if we don't have one, just make + -- it the same as the starting line number + end_lnum = (location.end_lnum or location.lnum) - 1, + -- Which column does the error start on? + col = math.max((location.col or 1) - 1, 0), + -- end_col does not appear to need 1 subtracted. + end_col = location.end_col, + -- Which severity: error, warning, or info? + severity = diagnostic_severity_map[location.type] or "E", + -- An error code + code = location.code, + -- The error message + message = location.text, + -- e.g. "rubocop" + source = location.linter_name, + } + ) + end end - end - vim.diagnostic.set( - vim.api.nvim_create_namespace('ale'), - buffer, - diagnostics, - { - virtual_text = virtualtext_enabled_set[vim.g.ale_virtualtext_cursor] ~= nil, - signs = signs, - } - ) + local set_signs = ale.var(buffer, "set_signs") + local sign_priority = ale.var(buffer, "sign_priority") + local signs + + if (set_signs == 1 or set_signs == true) and sign_priority then + -- If signs are enabled, set the priority for them. + local local_cfg = { priority = sign_priority } + local global_cfg = vim.diagnostic.config().signs + + if type(global_cfg) == "boolean" then + signs = local_cfg + elseif type(global_cfg) == "table" then + signs = vim.tbl_extend("force", global_cfg, local_cfg) + else + -- If a global function is defined, then define a function + -- that calls that function when Neovim calls our function. + signs = function(...) + return vim.tbl_extend("force", global_cfg(...), local_cfg) + end + end + end + + vim.diagnostic.set( + vim.api.nvim_create_namespace("ale"), + buffer, + diagnostics, + { + virtual_text = + virtualtext_enabled_set[vim.g.ale_virtualtext_cursor] ~= nil, + signs = signs, + } + ) end return module diff --git a/lua/ale/init.lua b/lua/ale/init.lua index a08e66db..521ef9ea 100644 --- a/lua/ale/init.lua +++ b/lua/ale/init.lua @@ -1,19 +1,19 @@ local ale = {} local global_settings = setmetatable({}, { - __index = function (_, key) + __index = function(_, key) return vim.g['ale_' .. key] end, - __newindex = function (_, key, value) + __newindex = function(_, key, value) vim.g['ale_' .. key] = value end }) local buffer_settings = setmetatable({}, { - __index = function (_, key) + __index = function(_, key) return vim.b['ale_' .. key] end, - __newindex = function (_, key, value) + __newindex = function(_, key, value) vim.b['ale_' .. key] = value end }) @@ -30,17 +30,79 @@ ale.set_buffer = function(c) end end +---(when called) Set global ALE settings, just like ale.setup.global. +---@class ALESetup +---@field global fun(c: table): nil -- Set global ALE settings. +---@field buffer fun(c: table): nil -- Set buffer-local ALE settings. +---@overload fun(c: table): nil +---@type ALESetup ale.setup = setmetatable({ - global = function(c) - ale.set_global(c) - end, - buffer = function(c) - ale.set_buffer(c) - end, + ---Set global ALE settings. + ---@param c table The table of ALE settings to set. + ---@return nil + global = function(c) + ale.set_global(c) + end, + ---Set buffer-local ALE settings. + ---@param c table The table of ALE settings to set. + ---@return nil + buffer = function(c) + ale.set_buffer(c) + end, }, { - __call = function(self, c) - self.global(c) - end, + __call = function(self, c) + self.global(c) + end, }) +---Get an ALE variable for a buffer (first) or globally (second) +---@param buffer number The buffer number to retreive the variable for. +---@param variable_name string The variable to retrieve. +---@return any value The value for the ALE variable +ale.var = function(buffer, variable_name) + variable_name = "ale_" .. variable_name + local exists, value = pcall(vim.api.nvim_buf_get_var, buffer, variable_name) + + if exists then + return value + end + + return vim.g[variable_name] +end + +---Escape a string for use in a shell command +---@param str string The string to escape. +---@return string escaped The escaped string. +ale.escape = function(str) + local shell = vim.fn.fnamemodify(vim.o.shell, ":t") + + if shell:lower() == "cmd.exe" then + local step1 + + if str:find(" ") then + step1 = '"' .. str:gsub('"', '""') .. '"' + else + step1 = str:gsub("([&|<>^])", "^%1") + end + + local percent_subbed = step1:gsub("%%", "%%%%") + + return percent_subbed + end + + return vim.fn.shellescape(str) +end + +---Create a prefix for a shell command for adding environment variables. +---@param variable_name string The environment variable name. +---@param value string The value to set for the environment variable. +---@return string prefix The shell code for prefixing a command. +ale.env = function(variable_name, value) + if vim.fn.has("win32") then + return "set " .. ale.escape(variable_name .. "=" .. value) .. " && " + end + + return variable_name .. "=" .. ale.escape(value) .. " " +end + return ale diff --git a/lua/ale/lsp.lua b/lua/ale/lsp.lua index 01c59c86..b5529af4 100644 --- a/lua/ale/lsp.lua +++ b/lua/ale/lsp.lua @@ -2,7 +2,9 @@ local module = {} module.start = function(config) -- Neovim's luaeval sometimes adds a Boolean key to table we need to remove. - if config.init_options[true] ~= nil then + if type(config.init_options) == "table" + and config.init_options[true] ~= nil + then config.init_options[true] = nil end diff --git a/run-tests b/run-tests index fb3e750d..a4ddb051 100755 --- a/run-tests +++ b/run-tests @@ -29,6 +29,7 @@ run_neovim_07_tests=1 run_neovim_08_tests=1 run_vim_80_tests=1 run_vim_90_tests=1 +run_lua_tests=1 run_linters=1 while [ $# -ne 0 ]; do @@ -46,12 +47,14 @@ while [ $# -ne 0 ]; do run_vim_90_tests=0 run_neovim_07_tests=0 run_neovim_08_tests=0 + run_lua_tests=0 run_linters=0 shift ;; --neovim-only) run_vim_80_tests=0 run_vim_90_tests=0 + run_lua_tests=0 run_linters=0 shift ;; @@ -59,6 +62,7 @@ while [ $# -ne 0 ]; do run_neovim_08_tests=0 run_vim_80_tests=0 run_vim_90_tests=0 + run_lua_tests=0 run_linters=0 shift ;; @@ -66,12 +70,14 @@ while [ $# -ne 0 ]; do run_neovim_07_tests=0 run_vim_80_tests=0 run_vim_90_tests=0 + run_lua_tests=0 run_linters=0 shift ;; --vim-only) run_neovim_07_tests=0 run_neovim_08_tests=0 + run_lua_tests=0 run_linters=0 shift ;; @@ -79,6 +85,7 @@ while [ $# -ne 0 ]; do run_neovim_07_tests=0 run_neovim_08_tests=0 run_vim_90_tests=0 + run_lua_tests=0 run_linters=0 shift ;; @@ -86,6 +93,7 @@ while [ $# -ne 0 ]; do run_neovim_07_tests=0 run_neovim_08_tests=0 run_vim_80_tests=0 + run_lua_tests=0 run_linters=0 shift ;; @@ -94,6 +102,15 @@ while [ $# -ne 0 ]; do run_vim_90_tests=0 run_neovim_07_tests=0 run_neovim_08_tests=0 + run_lua_tests=0 + shift + ;; + --lua-only) + run_vim_80_tests=0 + run_vim_90_tests=0 + run_neovim_07_tests=0 + run_neovim_08_tests=0 + run_linters=0 shift ;; --fast) @@ -119,6 +136,7 @@ while [ $# -ne 0 ]; do echo ' --vim-only Run tests only for Vim' echo ' --vim-80-only Run tests only for Vim 8.2' echo ' --vim-90-only Run tests only for Vim 9.0' + echo ' --lua-only Run only Lua tests' echo ' --linters-only Run only Vint and custom checks' echo ' --fast Run only the fastest Vim and custom checks' echo ' --help Show this help text' @@ -147,6 +165,7 @@ if [ $# -ne 0 ]; then # Don't run other tools when targeting tests. run_linters=0 + run_lua_tests=0 fi # Delete .swp files in the test directory, which cause Vim 8 to hang. @@ -250,6 +269,13 @@ for vim in $("$DOCKER" run --rm "$DOCKER_RUN_IMAGE" ls /vim-build/bin | grep '^n fi done +if ((run_lua_tests)); then + echo "Starting Lua tests..." + file_number=$((file_number+1)) + test/script/run-lua-tests $quiet_flag > "$output_dir/$file_number" 2>&1 & + pid_list="$pid_list $!" +fi + if ((run_linters)); then echo "Starting Vint..." file_number=$((file_number+1)) diff --git a/test/lua/.luarc.json b/test/lua/.luarc.json index 98766310..e6c6668f 100644 --- a/test/lua/.luarc.json +++ b/test/lua/.luarc.json @@ -11,9 +11,30 @@ "pending", "assert" ], + "workspace.checkThirdParty": false, "workspace.library": [ + "${3rd}/busted/library", + "${env:HOME}/.luarocks/share/lua/5.4", + "${env:HOME}/.luarocks/share/lua/5.3", + "${env:HOME}/.luarocks/share/lua/5.2", + "${env:HOME}/.luarocks/share/lua/5.1", "../../lua" ], + "runtime.pathStrict": true, + "runtime.path": [ + "?.lua", + "?/init.lua", + "../../lua/?.lua", + "../../lua/?/init.lua", + "${env:HOME}/.luarocks/share/lua/5.4/?.lua", + "${env:HOME}/.luarocks/share/lua/5.4/?/init.lua", + "${env:HOME}/.luarocks/share/lua/5.3/?.lua", + "${env:HOME}/.luarocks/share/lua/5.3/?/init.lua", + "${env:HOME}/.luarocks/share/lua/5.2/?.lua", + "${env:HOME}/.luarocks/share/lua/5.2/?/init.lua", + "${env:HOME}/.luarocks/share/lua/5.1/?.lua", + "${env:HOME}/.luarocks/share/lua/5.1/?/init.lua" + ], "runtime.version": "LuaJIT", "hint.enable": false } diff --git a/test/lua/ale_diagnostics_spec.lua b/test/lua/ale_diagnostics_spec.lua new file mode 100644 index 00000000..ae2ad894 --- /dev/null +++ b/test/lua/ale_diagnostics_spec.lua @@ -0,0 +1,232 @@ +local eq = assert.are.same +local diagnostics + +describe("ale.diagnostics.send", function() + local buffer_map + local signs_config + local diagnostic_set_calls + + setup(function() + _G.vim = { + api = { + nvim_buf_get_var = function(buffer, key) + local buffer_table = buffer_map[buffer] or {} + local value = buffer_table[key] + + if value == nil then + error(key .. " is missing") + end + + return value + end, + nvim_create_namespace = function() + return 42 + end, + }, + diagnostic = { + severity = {ERROR = 1, WARN = 2, INFO = 3}, + config = function() + return {signs = signs_config} + end, + set = function(namespace, bufnr, _diagnostics, opts) + table.insert(diagnostic_set_calls, { + namespace = namespace, + bufnr = bufnr, + diagnostics = _diagnostics, + opts = opts, + }) + end, + }, + tbl_extend = function(behavior, ...) + assert(behavior == "force", "We should only use `force`") + + local merged = {} + + for _, arg in ipairs({...}) do + for key, value in pairs(arg) do + merged[key] = value + end + end + + return merged + end, + g = {}, + } + + diagnostics = require("ale.diagnostics") + end) + + teardown(function() + _G.vim = nil + end) + + before_each(function() + buffer_map = {} + diagnostic_set_calls = {} + signs_config = false + _G.vim.g = {} + end) + + it("should set an empty list of diagnostics correctly", function() + diagnostics.send(7, {}) + + eq( + { + { + namespace = 42, + bufnr = 7, + diagnostics = {}, + opts = {virtual_text = false} + }, + }, + diagnostic_set_calls + ) + end) + + it("should handle basic case with all fields", function() + diagnostics.send(1, { + { + bufnr = 1, + lnum = 2, + end_lnum = 3, + col = 4, + end_col = 5, + type = "W", + code = "123", + text = "Warning message", + linter_name = "eslint", + }, + }) + eq({ + { + lnum = 1, + end_lnum = 2, + col = 3, + end_col = 5, + severity = vim.diagnostic.severity.WARN, + code = "123", + message = "Warning message", + source = "eslint", + }, + }, diagnostic_set_calls[1].diagnostics) + end) + + it("should default end_lnum to lnum when missing", function() + diagnostics.send(1, { + { + bufnr = 1, + lnum = 5, + col = 2, + end_col = 8, + type = "E", + text = "Error message", + linter_name = "mylinter", + }, + }) + eq({ + { + lnum = 4, + end_lnum = 4, + col = 1, + end_col = 8, + severity = vim.diagnostic.severity.ERROR, + code = nil, + message = "Error message", + source = "mylinter", + }, + }, diagnostic_set_calls[1].diagnostics) + end) + + it("should default col to 0 when missing", function() + diagnostics.send(1, { + { + bufnr = 1, + lnum = 10, + end_lnum = 12, + end_col = 6, + type = "I", + text = "Info message", + }, + }) + eq({ + { + lnum = 9, + end_lnum = 11, + col = 0, + end_col = 6, + severity = vim.diagnostic.severity.INFO, + code = nil, + message = "Info message", + source = nil, + }, + }, diagnostic_set_calls[1].diagnostics) + end) + + it("should ignore non-matching buffers", function() + diagnostics.send(1, { + { + bufnr = 2, + lnum = 1, + end_lnum = 2, + col = 1, + end_col = 4, + type = "W", + text = "Message", + }, + }) + eq({}, diagnostic_set_calls[1].diagnostics) + end) + + for _, set_signs_value in ipairs {1, true} do + describe("signs with setting set_signs = " .. tostring(set_signs_value), function() + before_each(function() + _G.vim.g.ale_set_signs = set_signs_value + _G.vim.g.ale_sign_priority = 10 + end) + + it("and global config as `false` should enable signs with the given priority", function() + diagnostics.send(7, {}) + eq({priority = 10}, diagnostic_set_calls[1].opts.signs) + end) + + it("and global config as a table should enable signs with the given priority", function() + signs_config = {foo = "bar", priority = 5} + diagnostics.send(7, {}) + eq( + {foo = "bar", priority = 10}, + diagnostic_set_calls[1].opts.signs + ) + end) + + it("and global config as a function should enable signs with the given priority", function() + signs_config = function() + return {foo = "bar", priority = 5} + end + diagnostics.send(7, {}) + + local local_signs = diagnostic_set_calls[1].opts.signs + + eq("function", type(local_signs)) + eq({foo = "bar", priority = 10}, local_signs()) + end) + end) + end + + it("should toggle virtual_text correctly", function() + for _, value in ipairs({"all", "2", 2, "current", "1", 1, true}) do + diagnostic_set_calls = {} + _G.vim.g.ale_virtualtext_cursor = value + diagnostics.send(7, {}) + + eq({virtual_text = true}, diagnostic_set_calls[1].opts) + end + + for _, value in ipairs({"disabled", "0", 0, false, nil}) do + diagnostic_set_calls = {} + _G.vim.g.ale_virtualtext_cursor = value + diagnostics.send(7, {}) + + eq({virtual_text = false}, diagnostic_set_calls[1].opts) + end + end) +end) diff --git a/test/lua/ale_env_spec.lua b/test/lua/ale_env_spec.lua new file mode 100644 index 00000000..1cceb5be --- /dev/null +++ b/test/lua/ale_env_spec.lua @@ -0,0 +1,56 @@ +local eq = assert.are.same +local ale = require("ale") + +describe("ale.env", function() + local is_win32 = false + + setup(function() + _G.vim = { + o = setmetatable({}, { + __index = function(_, key) + if key == "shell" then + if is_win32 then + return "cmd.exe" + end + + return "bash" + end + + return nil + end + }), + fn = { + has = function(feature) + return feature == "win32" and is_win32 + end, + -- Mock a very poor version of shellescape() for Unix + -- This shouldn't be called for Windows + shellescape = function(str) + return "'" .. str .. "'" + end, + fnamemodify = function(shell, _) + return shell + end + } + } + end) + + teardown(function() + _G.vim = nil + end) + + before_each(function() + is_win32 = false + end) + + it("should escape values correctly on Unix", function() + eq("name='xxx' ", ale.env('name', 'xxx')) + eq("name='foo bar' ", ale.env('name', 'foo bar')) + end) + + it("should escape values correctly on Windows", function() + is_win32 = true + eq('set name=xxx && ', ale.env('name', 'xxx')) + eq('set "name=foo bar" && ', ale.env('name', 'foo bar')) + end) +end) diff --git a/test/lua/ale_lsp_spec.lua b/test/lua/ale_lsp_spec.lua new file mode 100644 index 00000000..8f9b2974 --- /dev/null +++ b/test/lua/ale_lsp_spec.lua @@ -0,0 +1,224 @@ +local eq = assert.are.same +local lsp = require("ale.lsp") + +describe("ale.lsp.start", function() + local start_calls + local rpc_connect_calls + local vim_fn_calls + local defer_calls + + setup(function() + _G.vim = { + defer_fn = function(func, delay) + table.insert(defer_calls, {func, delay}) + end, + fn = setmetatable({}, { + __index = function(_, key) + return function(...) + table.insert(vim_fn_calls, {key, ...}) + + if key == "ale#lsp#GetLanguage" then + return "python" + end + + if key ~= "ale#lsp_linter#HandleLSPResponse" + and key ~= "ale#lsp#UpdateCapabilities" + and key ~= "ale#lsp#CallInitCallbacks" + then + assert(false, "Invalid ALE function: " .. key) + end + + return nil + end + end, + }), + lsp = { + rpc = { + connect = function(host, port) + return function(dispatch) + table.insert(rpc_connect_calls, { + host = host, + port = port, + dispatch = dispatch, + }) + end + end, + }, + start = function(...) + table.insert(start_calls, {...}) + + return 42 + end, + }, + } + end) + + teardown(function() + _G.vim = nil + end) + + before_each(function() + start_calls = {} + rpc_connect_calls = {} + vim_fn_calls = {} + defer_calls = {} + end) + + it("should start lsp programs with the correct arguments", function() + lsp.start({ + name = "server:/code", + cmd = "server", + root_dir = "/code", + -- This Boolean value somehow ends up in Dictionaries from + -- Vim for init_options, and we need to remove it. + init_options = {[true] = 123}, + }) + + -- Remove arguments with functions we can't apply equality checks + -- for easily. + for _, args in pairs(start_calls) do + args[1].handlers = nil + args[1].on_init = nil + args[1].get_language_id = nil + end + + eq({ + { + { + cmd = "server", + name = "server:/code", + root_dir = "/code", + init_options = {}, + }, + {attach = false, silent = true} + } + }, start_calls) + eq({}, vim_fn_calls) + end) + + it("should start lsp socket connections with the correct arguments", function() + lsp.start({ + name = "localhost:1234:/code", + host = "localhost", + port = 1234, + root_dir = "/code", + init_options = {foo = "bar"}, + }) + + local cmd + + -- Remove arguments with functions we can't apply equality checks + -- for easily. + for _, args in pairs(start_calls) do + cmd = args[1].cmd + args[1].cmd = nil + args[1].handlers = nil + args[1].on_init = nil + args[1].get_language_id = nil + end + + eq({ + { + { + name = "localhost:1234:/code", + root_dir = "/code", + init_options = {foo = "bar"}, + }, + {attach = false, silent = true} + } + }, start_calls) + + cmd("dispatch_value") + + eq({ + {dispatch = "dispatch_value", host = "localhost", port = 1234}, + }, rpc_connect_calls) + eq({}, vim_fn_calls) + end) + + it("should return the client_id value from vim.lsp.start", function() + eq(42, lsp.start({})) + end) + + it("should implement get_language_id correctly", function() + lsp.start({name = "server:/code"}) + + eq(1, #start_calls) + eq("python", start_calls[1][1].get_language_id(347, "ftype")) + eq({{"ale#lsp#GetLanguage", "server:/code", 347}}, vim_fn_calls) + end) + + it("should initialize clients with ALE correctly", function() + lsp.start({name = "server:/code"}) + + eq(1, #start_calls) + + start_calls[1][1].on_init({server_capabilities = {cap = 1}}) + + eq({ + {"ale#lsp#UpdateCapabilities", "server:/code", {cap = 1}}, + }, vim_fn_calls) + eq(1, #defer_calls) + eq(2, #defer_calls[1]) + eq("function", type(defer_calls[1][1])) + eq(0, defer_calls[1][2]) + + defer_calls[1][1]() + + eq({ + {"ale#lsp#UpdateCapabilities", "server:/code", {cap = 1}}, + {"ale#lsp#CallInitCallbacks", "server:/code"}, + }, vim_fn_calls) + end) + + it("should configure handlers correctly", function() + lsp.start({name = "server:/code"}) + + eq(1, #start_calls) + + local handlers = start_calls[1][1].handlers + local handler_names = {} + + -- get keys from handlers + for key, _ in pairs(handlers) do + -- add key to handler_names mapping + handler_names[key] = true + end + + eq({["textDocument/publishDiagnostics"] = true}, handler_names) + + handlers["textDocument/publishDiagnostics"](nil, { + { + lnum = 1, + end_lnum = 2, + col = 3, + end_col = 5, + severity = 1, + code = "123", + message = "Warning message", + }, + }) + + eq({ + { + "ale#lsp_linter#HandleLSPResponse", + "server:/code", + { + jsonrpc = "2.0", + method = "textDocument/publishDiagnostics", + params = { + { + lnum = 1, + end_lnum = 2, + col = 3, + end_col = 5, + severity = 1, + code = "123", + message = "Warning message", + }, + } + } + }, + }, vim_fn_calls) + end) +end) diff --git a/test/lua/ale_var_spec.lua b/test/lua/ale_var_spec.lua new file mode 100644 index 00000000..5c912724 --- /dev/null +++ b/test/lua/ale_var_spec.lua @@ -0,0 +1,51 @@ +local eq = assert.are.same +local ale = require("ale") + +describe("ale.var", function() + local buffer_map + + setup(function() + _G.vim = { + api = { + nvim_buf_get_var = function(buffer, key) + local buffer_table = buffer_map[buffer] or {} + local value = buffer_table[key] + + if value == nil then + error(key .. " is missing") + end + + return value + end, + }, + g = { + }, + } + end) + + teardown(function() + _G.vim = nil + end) + + before_each(function() + buffer_map = {} + _G.vim.g = {} + end) + + it("should return nil for undefined variables", function() + eq(nil, ale.var(1, "foo")) + end) + + it("should return buffer-local values, if set", function() + _G.vim.g.ale_foo = "global-value" + buffer_map[1] = {ale_foo = "buffer-value"} + + eq("buffer-value", ale.var(1, "foo")) + end) + + it("should return global values, if set", function() + _G.vim.g.ale_foo = "global-value" + + eq("global-value", ale.var(1, "foo")) + end) +end) diff --git a/test/lua/windows_escaping_spec.lua b/test/lua/windows_escaping_spec.lua new file mode 100644 index 00000000..86f5f52e --- /dev/null +++ b/test/lua/windows_escaping_spec.lua @@ -0,0 +1,61 @@ +local eq = assert.are.same +local ale = require("ale") + +describe("ale.escape for cmd.exe", function() + setup(function() + _G.vim = { + o = { + shell = "cmd.exe" + }, + fn = { + fnamemodify = function(shell, _) + return shell + end + } + } + end) + + teardown(function() + _G.vim = nil + end) + + it("should allow not escape paths without special characters", function() + eq("C:", ale.escape("C:")) + eq("C:\\", ale.escape("C:\\")) + eq("python", ale.escape("python")) + eq("C:\\foo\\bar", ale.escape("C:\\foo\\bar")) + eq("/bar/baz", ale.escape("/bar/baz")) + eq("nul", ale.escape("nul")) + eq("'foo'", ale.escape("'foo'")) + end) + + it("should escape Windows paths with spaces appropriately", function() + eq('"C:\\foo bar\\baz"', ale.escape('C:\\foo bar\\baz')) + eq('"^foo bar^"', ale.escape('^foo bar^')) + eq('"&foo bar&"', ale.escape('&foo bar&')) + eq('"|foo bar|"', ale.escape('|foo bar|')) + eq('"foo bar>"', ale.escape('>foo bar>')) + eq('"^foo bar^"', ale.escape('^foo bar^')) + eq('"\'foo\' \'bar\'"', ale.escape('\'foo\' \'bar\'')) + end) + + it("should use caret escapes on special characters", function() + eq('^^foo^^', ale.escape('^foo^')) + eq('^&foo^&', ale.escape('&foo&')) + eq('^|foo^|', ale.escape('|foo|')) + eq('^foo^>', ale.escape('>foo>')) + eq('^^foo^^', ale.escape('^foo^')) + eq('\'foo\'^^\'bar\'', ale.escape('\'foo\'^\'bar\'')) + end) + + it("should escape percent characters", function() + eq('%%foo%%', ale.escape('%foo%')) + eq('C:\foo%%\bar\baz%%', ale.escape('C:\foo%\bar\baz%')) + eq('"C:\foo bar%%\baz%%"', ale.escape('C:\foo bar%\baz%')) + eq('^^%%foo%%', ale.escape('^%foo%')) + eq('"^%%foo%% %%bar%%"', ale.escape('^%foo% %bar%')) + eq('"^%%foo%% %%bar%% """""', ale.escape('^%foo% %bar% ""')) + end) +end) diff --git a/test/script/run-lua-tests b/test/script/run-lua-tests new file mode 100755 index 00000000..ac538425 --- /dev/null +++ b/test/script/run-lua-tests @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e +set -u + +docker_flags=(--rm -v "$PWD:/testplugin" -v "$PWD/test:/home" -w /testplugin/test/lua "$DOCKER_RUN_IMAGE") + +quiet=0 + +while [ $# -ne 0 ]; do + case $1 in + -q) + quiet=1 + shift + ;; + --) + shift + break + ;; + -?*) + echo "Invalid argument: $1" 1>&2 + exit 1 + ;; + *) + break + ;; + esac +done + +function filter-busted-output() { + local hit_failure_line=0 + + while read -r; do + if ((quiet)); then + # If we're using the quiet flag, the filter out lines until we hit + # the first line with "Failure" and then print the rest. + if ((hit_failure_line)); then + echo "$REPLY" + elif [[ "$REPLY" = *'Failure'* ]]; then + hit_failure_line=1 + echo "$REPLY" + fi + else + echo "$REPLY" + fi + done +} + +exit_code=0 + +set -o pipefail +"$DOCKER" run -a stdout "${docker_flags[@]}" /usr/bin/busted-5.1 \ + -m '../../lua/?.lua;../../lua/?/init.lua' . \ + --output utfTerminal | filter-busted-output || exit_code=$? +set +o pipefail + +exit "$exit_code"