diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..0fcd913 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/refs/heads/main/schema/markdownlint-config-schema.json", + + "extends": "markdownlint/style/prettier" +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..53d1469 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,2 @@ +indent_type = "Spaces" +quote_style = "AutoPreferSingle" diff --git a/README.md b/README.md index 18dba20..d6fda19 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # nvim-jsonnet Features: -* Provide functions to evaluate Jsonnet code inside a split view -* Extend nvim-treesitter highlighting with references and linting + +- Provide functions to evaluate Jsonnet code inside a split view +- Extend nvim-treesitter highlighting with references and linting ## Usage @@ -41,9 +42,10 @@ This plugin does not provide syntax highlighting, folding, formatting or linting LSP with jsonnet-language-server provides formatting and linting out of the box, this config uses [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/). -See [Usage](#Usage) to enable opinionated setup. +See [Usage](#usage) to enable opinionated setup. Tip: configure format on save for all LSP buffers: + ```lua -- Format on save vim.api.nvim_create_autocmd( @@ -61,7 +63,7 @@ vim.api.nvim_create_autocmd( [nvim-dap](https://github.com/mfussenegger/nvim-dap) provides a way to run the [jsonnet-debugger](https://github.com/grafana/jsonnet-debugger), this works great in combination with [nvim-dap-ui](https://github.com/rcarriga/nvim-dap-ui). -Install the debugger with `go install github.com/grafana/jsonnet-debugger@v0.1.0` and see [Usage](#Usage) to enable. +Install the debugger with `go install github.com/grafana/jsonnet-debugger@v0.1.0` and see [Usage](#usage) to enable. ### Treesitter @@ -80,6 +82,49 @@ vim.wo.foldexpr = 'v:lua.vim.treesitter.foldexpr()' vim.wo.foldlevel = 1000 ``` +### Window layouts + +Various window layouts are available. See [./lua/nvim-jsonnet/config.lua] for +full details of all the configuration options and the defaults. Set just what +you need: unchanged values will be taken from the defaults. + +Use a floating window sized to 40% of the screen width: + +```lua +window = { + layout = "float", + width = 0.4 +} +``` + +![Floating window](./screenshots/float.png) + +Integrate with [`edgy.nvim`][edgy]'s sidebar: + +```lua +// This is an example `lazy.nvim` configuration +{ + "folke/edgy.nvim", + + optional = true, + + opts = function(_, opts) + opts.right = opts.right or {} + + table.insert(opts.right, { + ft = "jsonnet-output", + title = "Jsonnet", + + size = { width = 50 }, + }) + end, +}, +``` + +![Edgy sidebar](./screenshots/edgy.png) + +[edgy]: https://github.com/folke/edgy.nvim + ### null-ls/cbfmt For formatting code blocks inside Markdown you can use null-ls with `cbfmt`. diff --git a/lua/jsonnet/utils.lua b/lua/jsonnet/utils.lua deleted file mode 100644 index bed146b..0000000 --- a/lua/jsonnet/utils.lua +++ /dev/null @@ -1,52 +0,0 @@ -function table.shallow_copy(t) - local t2 = {} - for k, v in pairs(t) do - t2[k] = v - end - return t2 -end - -local function getBuffer(name, filetype, opts) - local bufnr = vim.fn.bufnr(name) - if bufnr == -1 then - bufnr = vim.fn.bufadd(name) - vim.fn.win_execute(vim.fn.win_getid(1), string.format('%s sbuffer %d', opts.mods, bufnr)) - vim.api.nvim_buf_set_option(bufnr, 'buflisted', false) - vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile') - vim.api.nvim_buf_set_option(bufnr, 'bufhidden', 'wipe') - vim.api.nvim_buf_set_option(bufnr, 'swapfile', false) - vim.api.nvim_buf_set_option(bufnr, 'filetype', filetype or '') - end - return bufnr -end - -local function runJob(cmd, args, filetype, opts) - local argsWithFile = table.shallow_copy(args) - argsWithFile[#argsWithFile + 1] = vim.fn.expand('%') - - local ex = require('plenary.job'):new({ - command = cmd, - args = argsWithFile, - cwd = vim.loop.cwd(), - enable_recording = true, - enabled_recording = true, - }) - - local stdout, code = ex:sync() - - if code ~= 0 then - local stderr = ex:stderr_result() - vim.notify( - ('cmd (%q) failed:\n%s'):format(cmd, vim.inspect(stderr)), - vim.log.levels.WARN - ) - return - end - - local bufnr = getBuffer(cmd .. ' ' .. table.concat(args, ' '), filetype, opts) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, stdout) -end - -return { - RunCommand = runJob, -} diff --git a/lua/nvim-jsonnet.lua b/lua/nvim-jsonnet.lua deleted file mode 100644 index df853a5..0000000 --- a/lua/nvim-jsonnet.lua +++ /dev/null @@ -1,96 +0,0 @@ -local utils = require('jsonnet.utils') -local stringtoboolean = { ['true'] = true, ['false'] = false } - -local M = {} - -local defaults = { - jsonnet_bin = os.getenv('JSONNET_BIN') or 'jsonnet', - jsonnet_args = { '-J', 'vendor', '-J', 'lib' }, - jsonnet_string_bin = os.getenv('JSONNET_BIN') or 'jsonnet', - jsonnet_string_args = { '-S', '-J', 'vendor', '-J', 'lib' }, - use_tanka_if_possible = stringtoboolean[os.getenv('NVIM_JSONNET_USE_TANKA') or 'true'], - - load_lsp_config = false, - capabilities = vim.lsp.protocol.make_client_capabilities(), - - -- default to false to not break existing installs - load_dap_config = false, - jsonnet_debugger_bin = 'jsonnet-debugger', - jsonnet_debugger_args = { '-s', '-d', '-J', 'vendor', '-J', 'lib' }, -} - -M.setup = function(options) - M.options = vim.tbl_deep_extend('force', {}, defaults, options or {}) - - if M.options.use_tanka_if_possible then - -- Use Tanka if `tk tool jpath` works. - local _ = vim.fn.system('tk tool jpath ' .. vim.fn.shellescape(vim.fn.expand('%'))) - if vim.api.nvim_get_vvar('shell_error') == 0 then - M.options.jsonnet_bin = 'tk' - M.options.jsonnet_args = { 'eval' } - end - end - - vim.api.nvim_create_user_command( - 'JsonnetPrintConfig', - function() - print(vim.inspect(M.options)) - end, {}) - - vim.api.nvim_create_user_command( - 'JsonnetEval', - function(opts) - utils.RunCommand(M.options.jsonnet_bin, M.options.jsonnet_args, 'json', opts) - end, - { nargs = '?' }) - - vim.api.nvim_create_user_command( - 'JsonnetEvalString', - function(opts) - utils.RunCommand(M.options.jsonnet_string_bin, M.options.jsonnet_string_args, '', opts) - end, - { nargs = '?' }) - - vim.api.nvim_create_autocmd( - 'FileType', - { - pattern = { 'jsonnet' }, - callback = function() - vim.keymap.set('n', 'j', 'JsonnetEval') - vim.keymap.set('n', 'k', 'JsonnetEvalString') - vim.keymap.set('n', 'l', ':<\',\'>!jsonnetfmt -') - vim.opt_local.foldlevelstart = 1 - end, - }) - - local hasLspconfig, lspconfig = pcall(require, 'lspconfig') - if M.options.load_lsp_config and hasLspconfig then - lspconfig.jsonnet_ls.setup { - capabilities = M.options.capabilities, - settings = { - formatting = { - UseImplicitPlus = stringtoboolean[os.getenv('JSONNET_IMPLICIT_PLUS')] or false - } - } - } - end - - local hasDap, dap = pcall(require, 'dap') - if M.options.load_dap_config and hasDap then - dap.adapters.jsonnet = { - type = 'executable', - command = M.options.jsonnet_debugger_bin, - args = M.options.jsonnet_debugger_args, - } - dap.configurations.jsonnet = { - { - type = 'jsonnet', - request = 'launch', - name = 'debug', - program = '${file}', - } - } - end -end - -return M diff --git a/lua/nvim-jsonnet/buffer.lua b/lua/nvim-jsonnet/buffer.lua new file mode 100644 index 0000000..c3768de --- /dev/null +++ b/lua/nvim-jsonnet/buffer.lua @@ -0,0 +1,481 @@ +---@class nvim-jsonnet.Buffer +---@field augroup number? The ID of the autocmd group +---@field buffer_number number? Buffer number +---@field config nvim-jsonnet.Config|{} Configuration options +---@field layout string? Current layout mode +---@field name string Buffer name +---@field on_buf_create fun(buf: nvim-jsonnet.Buffer)? Function to call when buffer is created +---@field private help string Help message to display +---@field private job_id number? ID of the running job +---@field source_buffer_number number? Source buffer number +---@field source_window_number number? Source window number +local Buffer = {} +Buffer.__index = Buffer + +--- Create a new buffer +---@param name string The buffer name +---@param help string Help message to display +---@param on_buf_create fun(buf: nvim-jsonnet.Buffer)? Function to call when buffer is created +---@return nvim-jsonnet.Buffer +function Buffer.new(name, help, on_buf_create) + local self = setmetatable({}, Buffer) + self.name = name + self.help = help + self.on_buf_create = on_buf_create + self.layout = nil + self.config = {} + self.job_id = nil + + return self +end + +--- Returns whether the buffer window is visible and its window number (if it is). +---@return number|nil The window number if visible, nil otherwise +function Buffer:visible() + if not self:buf_valid() then + return nil + end + + -- Check if our buffer is visible in any window + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == self.buffer_number then + return win + end + end + + return nil +end +--- +--- Returns whether the buffer window is focused. +---@return boolean +function Buffer:focused() + local window_number = self:visible() + + return window_number ~= nil and vim.api.nvim_get_current_win() == window_number +end + +--- Check if the buffer is valid +---@return boolean +function Buffer:buf_valid() + return self.buffer_number ~= nil + and vim.api.nvim_buf_is_valid(self.buffer_number) + and vim.api.nvim_buf_is_loaded(self.buffer_number) +end + +--- Validate the buffer +function Buffer:validate() + if self:buf_valid() then + return + end + + self.buffer_number = self:create() + if self.on_buf_create ~= nil then + self:on_buf_create() + end +end + +--- Create the buffer +---@return number Buffer number +function Buffer:create() + local buffer_number = vim.api.nvim_create_buf(false, true) + vim.bo[buffer_number].modifiable = false + vim.api.nvim_buf_set_name(buffer_number, 'jsonnet-output://' .. self.name) + + -- Track buffer deletion, so we can abort any running jobs + vim.api.nvim_create_autocmd('BufDelete', { + buffer = buffer_number, + callback = function() + self:cancel_running_job() + self.buffer_number = nil + end, + once = true, + }) + + return buffer_number +end + +--- Setup autocmds to track source buffer visibility +function Buffer:setup_source_tracking() + if not self.source_buffer_number then + return + end + + if self.augroup ~= nil then + pcall(function() + vim.api.nvim_del_augroup_by_id(self.augroup) + end) + end + + self.augroup = vim.api.nvim_create_augroup('JsonnetAutoclose_' .. self.buffer_number, { clear = true }) + + vim.api.nvim_create_autocmd({ 'BufWinLeave', 'BufWinEnter', 'WinClosed', 'WinEnter', 'WinLeave' }, { + group = self.augroup, + callback = function() + -- Check if source buffer is visible + local source_visible = false + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == self.source_buffer_number then + source_visible = true + break + end + end + + if source_visible then + return + end + + -- If source is not visible in any window, close the output + self:close() + end, + }) +end + +--- Clean up any autocmds we created +function Buffer:cleanup_source_tracking() + if self.augroup == nil then + return + end + + pcall(function() + vim.api.nvim_del_augroup_by_id(self.augroup) + end) + self.augroup = nil +end + +--- Setup floating window behavior for this buffer +---@param width number The window width +---@param height number The window height +---@param window nvim-jsonnet.Config.Window Window configuration +function Buffer:setup_float(width, height, window) + local win_opts = { + style = 'minimal', + width = width, + height = height, + zindex = window.zindex, + relative = window.relative, + border = window.border, + title = window.title, + row = window.row or math.floor((vim.o.lines - height) / 2), + col = window.col or math.floor((vim.o.columns - width) / 2), + footer = self.help, + } + + local window_number = vim.api.nvim_open_win(self.buffer_number, false, win_opts) + vim.api.nvim_set_option_value('winblend', 10, { win = window_number }) + vim.api.nvim_set_option_value('winhl', 'Normal:FloatWindow', { win = window_number }) + + local float_augroup = vim.api.nvim_create_augroup('FloatWindowBehaviour_' .. self.buffer_number, { clear = true }) + + -- Close the window on any cursor movement outside the float + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'WinClosed' }, { + group = float_augroup, + callback = function() + -- Only close if we're not in the floating window + if self:focused() then + return + end + + self:close() + + pcall(vim.api.nvim_del_augroup_by_id, float_augroup) + end, + }) + + -- Or when the float is explicitly closed + vim.api.nvim_create_autocmd('BufLeave', { + group = float_augroup, + buffer = self.buffer_number, + callback = function() + self:close() + + pcall(vim.api.nvim_del_augroup_by_id, float_augroup) + end, + }) +end + +--- Setup vertical split window for this buffer +---@param width number The window width +function Buffer:setup_vertical(width) + local orig = vim.api.nvim_get_current_win() + local cmd = 'vsplit' + if width ~= 0 then + cmd = width .. cmd + end + if vim.api.nvim_get_option_value('splitright', {}) then + cmd = 'botright ' .. cmd + else + cmd = 'topleft ' .. cmd + end + vim.cmd(cmd) + + local window_number = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(window_number, self.buffer_number) + + vim.api.nvim_set_current_win(orig) +end + +--- Setup horizontal split window for this buffer +---@param height number The window height +function Buffer:setup_horizontal(height) + local orig = vim.api.nvim_get_current_win() + local cmd = 'split' + if height ~= 0 then + cmd = height .. cmd + end + if vim.api.nvim_get_option_value('splitbelow', {}) then + cmd = 'botright ' .. cmd + else + cmd = 'topleft ' .. cmd + end + vim.cmd(cmd) + + local window_number = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(window_number, self.buffer_number) + + vim.api.nvim_set_current_win(orig) +end + +--- Setup common window options +function Buffer:setup_window_options() + local window_number = self:visible() + + if not window_number or not vim.api.nvim_win_is_valid(window_number) then + return + end + + vim.wo[window_number].wrap = true + vim.wo[window_number].linebreak = true + vim.wo[window_number].cursorline = true + vim.wo[window_number].conceallevel = 2 + vim.wo[window_number].foldlevel = 99 +end + +--- Open the buffer window. +---@param window nvim-jsonnet.Config.Window Config options for the output window +function Buffer:open(window) + self:validate() + + local layout = window.layout + if type(layout) == 'function' then + layout = layout() + end + + local width = window.width > 1 and window.width or math.floor(vim.o.columns * window.width) + local height = window.height > 1 and window.height or math.floor(vim.o.lines * window.height) + + -- If layout changed or window isn't visible, we'll close and reopen + if self.layout ~= layout or not self:visible() then + self:close() + end + + self.layout = layout + + if self:visible() then + return + end + + if layout == 'float' then + self:setup_float(width, height, window) + elseif layout == 'vertical' then + self:setup_vertical(width) + elseif layout == 'horizontal' then + self:setup_horizontal(height) + elseif layout == 'replace' then + local current_window_number = vim.api.nvim_get_current_win() + local current_buffer_number = vim.api.nvim_win_get_buf(current_window_number) + + -- Save where we came from + self.source_window_number = current_window_number + self.source_buffer_number = current_buffer_number + + -- Replace the buffer in the current window + vim.api.nvim_win_set_buf(current_window_number, self.buffer_number) + end + + if layout ~= 'replace' then + self:setup_source_tracking() + end + + self:setup_window_options() +end + +--- Close the buffer window. +function Buffer:close() + local window_number = self:visible() + + if not window_number then + return + end + + if self:focused() then + local mode = vim.fn.mode():lower() + if mode:find('v') then + vim.cmd([[execute "normal! \"]]) + elseif mode ~= 'n' then + vim.cmd('stopinsert') + end + end + + if self.layout == 'replace' then + self:restore() + return + end + + -- Check if window is still valid before trying to close it + if window_number and vim.api.nvim_win_is_valid(window_number) then + vim.api.nvim_win_close(window_number, true) + end + + self:cleanup_source_tracking() +end + +--- Toggle the buffer window. +---@param window nvim-jsonnet.Config.Window +function Buffer:toggle(window) + if self:visible() then + self:close() + else + self:open(window) + end +end + +--- Focus the buffer window. +function Buffer:focus() + local window_number = self:visible() + + if not window_number then + return + end + + vim.api.nvim_set_current_win(window_number) +end + +--- Restore the original buffer +function Buffer:restore() + if not self.source_window_number or not self.source_buffer_number then + return + end + + if not vim.api.nvim_win_is_valid(self.source_window_number) then + return + end + + vim.api.nvim_win_set_buf(self.source_window_number, self.source_buffer_number) + vim.api.nvim_win_set_hl_ns(self.source_window_number, 0) + + -- Manually trigger BufEnter event as nvim_win_set_buf does not trigger it + vim.schedule(function() + vim.cmd(string.format('doautocmd BufEnter %s', self.source_buffer_number)) + end) +end + +--- Set the buffer content +---@param content string +---@param filetype string +function Buffer:set_content(content, filetype) + self:validate() + + vim.bo[self.buffer_number].modifiable = true + vim.api.nvim_buf_set_lines(self.buffer_number, 0, -1, false, vim.split(content, '\n')) + vim.bo[self.buffer_number].modifiable = false + + vim.bo[self.buffer_number].filetype = filetype +end + +--- Run a command and show the output in this buffer +---@param cmd string The command to run +---@param args string[] The arguments to pass to the command +---@param cwd string? The working directory +---@param filetype string The filetype to set for the buffer +---@param window nvim-jsonnet.Config.Window The window configuration, if a new buffer needs to be created +---@param return_focus boolean Whether to return focus to the source window after running the command +function Buffer:run_command(cmd, args, cwd, filetype, window, return_focus) + self:validate() + + self:cancel_running_job() + + local output_lines = {} + local error_lines = {} + + self.job_id = vim.fn.jobstart({ cmd, unpack(args) }, { + stdout_buffered = false, + stderr_buffered = false, + cwd = cwd, + + on_stdout = function(_, data) + if not data or #data == 0 then + return + end + + for _, line in ipairs(data) do + if line ~= '' then + table.insert(output_lines, line) + end + end + end, + + on_stderr = function(_, data) + if not data or #data == 0 then + return + end + + for _, line in ipairs(data) do + if line ~= '' then + table.insert(error_lines, line) + end + end + end, + + on_exit = function(_, exit_code) + self.job_id = nil + + if not self:buf_valid() then + return + end + + if exit_code ~= 0 then + -- Format error message for notification + local error_header = 'Command `' + .. cmd + .. ' ' + .. table.concat(args, ' ') + .. '` failed with exit code: ' + .. exit_code + local error_msg = error_header .. '\n' .. table.concat(error_lines, '\n') + + -- Display error notification + vim.notify(error_msg, vim.log.levels.ERROR) + + return + end + + self:set_content(table.concat(output_lines, '\n'), filetype) + + -- Force close and reopen to ensure proper window state + self:close() + self:open(window) + + if return_focus and self.source_window_number and vim.api.nvim_win_is_valid(self.source_window_number) then + vim.api.nvim_set_current_win(self.source_window_number) + end + end, + }) + + if self.job_id <= 0 then + vim.notify('Failed to start command: ' .. cmd .. ' ' .. table.concat(args, ' '), vim.log.levels.ERROR) + self:close() + self.job_id = nil + end +end + +--- Cancel the running job if there is one +function Buffer:cancel_running_job() + if not self.job_id then + return + end + + vim.fn.jobstop(self.job_id) + self.job_id = nil +end + +return Buffer diff --git a/lua/nvim-jsonnet/config.lua b/lua/nvim-jsonnet/config.lua new file mode 100644 index 0000000..6f52f0a --- /dev/null +++ b/lua/nvim-jsonnet/config.lua @@ -0,0 +1,144 @@ +local utils = require('nvim-jsonnet.utils') + +---@alias nvim-jsonnet.config.Layout 'vertical'|'horizontal'|'float'|'replace' + +---@class nvim-jsonnet.config.KeyMapping +---@field key string The key sequence (e.g., 'j', 'j') +---@field filetype? string|table Filetype(s) for the mapping +---@field desc string Description shown in help/which-key +---@field mode 'n'|'v'|'i'|'x' The mode(s) for the mapping +---@field cmd string|fun(nvim_jsonnet: nvim-jsonnet) The command string or Lua function to execute +---@field enabled boolean Whether the mapping is enabled + +---@class nvim-jsonnet.config.KeyMappingGroup: table + +---@class nvim-jsonnet.config.KeysConfig: nvim-jsonnet.config.KeyMappingGroup +---@field eval? nvim-jsonnet.config.KeyMapping Mapping for evaluation +---@field eval_string? nvim-jsonnet.config.KeyMapping Mapping for string evaluation +---@field format? nvim-jsonnet.config.KeyMapping Mapping for formatting + +---@class nvim-jsonnet.config.OutputKeysConfig: nvim-jsonnet.config.KeyMappingGroup +---@field toggle_output? nvim-jsonnet.config.KeyMapping Mapping for toggling output +---@field close? nvim-jsonnet.config.KeyMapping Mapping for closing output buffer + +---@class nvim-jsonnet.Config.Window +---@field layout nvim-jsonnet.config.Layout|fun():string Layout mode of the output window +---@field width number Fractional width (when <= 1) or absolute columns (when > 1) +---@field height number Fractional height (when <= 1) or absolute rows (when > 1) +---@field relative 'editor'|'win'|'cursor'|'mouse' Position relative to (floating windows only) +---@field border 'none'|'single'|'double'|'rounded'|'solid'|'shadow' Window border style (floating windows only) +---@field row? number Row position of the window, centered by default (floating windows only) +---@field col? number Column position of the window, centered by default (floating windows only) +---@field title? string Title of the output window (floating windows only) +---@field footer? string Footer of the output window (floating windows only) +---@field zindex? number Z-index for floating windows (floating windows only) + +---@class nvim-jsonnet.Config +---@field jsonnet_bin string Path to jsonnet executable +---@field jsonnet_args string[] Arguments for jsonnet command +---@field jsonnet_string_bin string Path to jsonnet executable for string output +---@field jsonnet_string_args string[] Arguments for jsonnet string command +---@field use_tanka_if_possible boolean Whether to use Tanka if available +---@field load_lsp_config boolean Whether to load LSP configuration +---@field capabilities table LSP capabilities +---@field load_dap_config boolean Whether to load DAP configuration +---@field jsonnet_debugger_bin string Path to jsonnet debugger executable +---@field jsonnet_debugger_args string[] Arguments for jsonnet debugger +---@field output_filetype string Filetype for the output buffer +---@field return_focus boolean Whether to return focus to source window after evaluation +---@field show_errors_in_buffer boolean Whether to show errors in the output buffer (true) or as notifications (false) +---@field key_prefix string Prefix for key mappings +---@field keys? nvim-jsonnet.config.KeysConfig Key mapping configuration +---@field output_keys? nvim-jsonnet.config.OutputKeysConfig Key mapping configuration for jsonnet and the output buffer +---@field setup_mappings boolean Whether to set up mappings automatically +---@field window? nvim-jsonnet.Config.Window Window configuration options + +---@type nvim-jsonnet.Config +local config = { + jsonnet_bin = os.getenv('JSONNET_BIN') or 'jsonnet', + jsonnet_args = { '-J', 'vendor', '-J', 'lib' }, + jsonnet_string_bin = os.getenv('JSONNET_BIN') or 'jsonnet', + jsonnet_string_args = { '-S', '-J', 'vendor', '-J', 'lib' }, + use_tanka_if_possible = utils.stringtoboolean[os.getenv('NVIM_JSONNET_USE_TANKA') or 'true'], + + load_lsp_config = false, + capabilities = vim.lsp.protocol.make_client_capabilities(), + + load_dap_config = false, + jsonnet_debugger_bin = 'jsonnet-debugger', + jsonnet_debugger_args = { '-s', '-d', '-J', 'vendor', '-J', 'lib' }, + + output_filetype = 'json', -- Default output filetype for evaluation + return_focus = true, -- Whether to return focus to source window after evaluation + show_errors_in_buffer = false, -- Whether to show errors in buffer (true) or as notifications (false) + + -- A prefix prepended to all key mappings + key_prefix = '', + + keys = { + eval = { + key = 'j', + desc = 'Evaluate Jsonnet file', + mode = 'n', + cmd = 'JsonnetEval', + enabled = true, + }, + eval_string = { + key = 'k', + desc = 'Evaluate Jsonnet file as string', + mode = 'n', + cmd = 'JsonnetEvalString', + enabled = true, + }, + format = { + key = 'l', + desc = 'Format Jsonnet file', + mode = 'n', + cmd = 'JsonnetFormat', + enabled = true, + }, + }, + + -- These keybindings are active in both `jsonnet` files and also the output + -- window + output_keys = { + toggle_output = { + key = 'o', + desc = 'Toggle Jsonnet output buffer', + mode = 'n', + cmd = 'JsonnetToggle', + enabled = true, + }, + close = { + key = 'q', + desc = 'Close Jsonnet output buffer', + mode = 'n', + cmd = function(nvim_jsonnet) + if nvim_jsonnet.output_buffer == nil then + return + end + + nvim_jsonnet.output_buffer:close() + end, + enabled = true, + }, + }, + + setup_mappings = true, + + window = { + layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' + width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 + height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 + -- Options below only apply to floating windows + relative = 'editor', -- 'editor', 'win', 'cursor', 'mouse' + border = 'single', -- 'none', 'single', 'double', 'rounded', 'solid', 'shadow' + -- row = nil, + -- col = nil, + title = 'Jsonnet Output', -- title of output window + -- footer = nil, + zindex = 1, -- determines if window is on top or below other floating windows + }, +} + +return config diff --git a/lua/nvim-jsonnet/init.lua b/lua/nvim-jsonnet/init.lua new file mode 100644 index 0000000..5f215b8 --- /dev/null +++ b/lua/nvim-jsonnet/init.lua @@ -0,0 +1,202 @@ +local Buffer = require('nvim-jsonnet.buffer') +local job = require('nvim-jsonnet.job') +local utils = require('nvim-jsonnet.utils') + +--- A custom filetype, mapped to `json`, used by `edgy.nvim` to manage the +--- output buffer. +local filetype = 'jsonnet-output' + +--- @class nvim-jsonnet +--- @field did_setup boolean Indicates if the plugin has been set up +--- @field output_buffer? nvim-jsonnet.Buffer The reusable output buffer for jsonnet evaluation +--- @field options nvim-jsonnet.Config The configuration options for the plugin +local M = { + did_setup = false, + output_buffer = nil, +} + +--- Set key mappings +---@param mappings nvim-jsonnet.config.KeyMappingGroup The mappings to set +local function apply_mappings(mappings) + if not M.options.setup_mappings then + return + end + + for _, mapping_config in pairs(mappings) do + if not mapping_config.enabled then + goto continue + end + + local cmd = type(mapping_config.cmd) == 'function' and function() + mapping_config.cmd(M) + end or mapping_config.cmd + + vim.keymap.set( + mapping_config.mode, + M.options.key_prefix .. mapping_config.key, + cmd, + { desc = mapping_config.desc, silent = true, noremap = true, buffer = true } + ) + + ::continue:: + end +end + +--- Get the current buffer's filepath +--- @return string path, string dir +local function get_buffer_path() + local bufname = vim.api.nvim_buf_get_name(0) + local path = vim.fn.fnamemodify(bufname, ':p') + local dir = vim.fn.fnamemodify(path, ':h') + return path, dir +end + +--- Initialise the output buffer if not already created +local function ensure_output_buffer() + if M.output_buffer then + return + end + + local how_to_close = { + 'Perform any movement', + } + + if M.options.setup_mappings and M.options.output_keys.close.enabled then + table.insert(how_to_close, 'press ' .. M.options.key_prefix .. M.options.output_keys.close.key) + end + + local close_message = table.concat(how_to_close, ' or ') .. ' to close this buffer' + + M.output_buffer = Buffer.new('jsonnet-output', close_message, function(buf) + M.output_buffer = buf + end) +end + +-- Run the given jsonnet command and output to our shared buffer +local function run_jsonnet(jsonnet, args, ft) + ensure_output_buffer() + + -- Get file path and directory + local path, dir = get_buffer_path() + + -- Build command arguments + local args_copy = vim.deepcopy(args) + table.insert(args_copy, path) + + -- Open output buffer and run command + M.output_buffer:run_command(jsonnet, args_copy, dir, ft, M.options.window, M.options.return_focus) +end + +-- Evaluate the current buffer as Jsonnet +local function eval_jsonnet() + run_jsonnet(M.options.jsonnet_bin, M.options.jsonnet_args, filetype) +end + +-- Evaluate the current buffer as Jsonnet string +local function eval_jsonnet_string() + run_jsonnet(M.options.jsonnet_string_bin, M.options.jsonnet_string_args, 'text') +end + +-- Format the current buffer using jsonnetfmt +local function format_jsonnet() + vim.cmd('!jsonnetfmt %') +end + +local function do_setup(options) + if M.did_setup then + return + end + + M.did_setup = true + + -- Merge user options with defaults + M.options = vim.tbl_deep_extend('force', {}, require('nvim-jsonnet.config'), options or {}) + + if M.options.use_tanka_if_possible then + -- Use Tanka if `tk tool jpath` works. + local result = job.system({ 'tk', 'tool', 'jpath', vim.fn.expand('%') }) + if result.code == 0 then + M.options.jsonnet_bin = 'tk' + M.options.jsonnet_args = { 'eval' } + end + end + + M.eval_jsonnet = eval_jsonnet + M.eval_jsonnet_string = eval_jsonnet_string + M.format_jsonnet = format_jsonnet + + -- Create user commands + vim.api.nvim_create_user_command('JsonnetPrintConfig', function() + print(vim.inspect(M.options)) + end, { desc = 'Print Jsonnet plugin configuration' }) + + vim.api.nvim_create_user_command('JsonnetEval', eval_jsonnet, { nargs = '?', desc = 'Evaluate Jsonnet file' }) + + vim.api.nvim_create_user_command( + 'JsonnetEvalString', + eval_jsonnet_string, + { nargs = '?', desc = 'Evaluate Jsonnet file as string' } + ) + + vim.api.nvim_create_user_command('JsonnetFormat', format_jsonnet, { desc = 'Format Jsonnet file' }) + + vim.api.nvim_create_user_command('JsonnetToggle', function() + ensure_output_buffer() + M.output_buffer:toggle(M.options.window) + end, { desc = 'Toggle Jsonnet output buffer' }) + + -- Set up LSP if requested + local hasLspconfig, lspconfig = pcall(require, 'lspconfig') + if M.options.load_lsp_config and hasLspconfig then + lspconfig.jsonnet_ls.setup({ + capabilities = M.options.capabilities, + settings = { + formatting = { + UseImplicitPlus = utils.stringtoboolean[os.getenv('JSONNET_IMPLICIT_PLUS')] or false, + }, + }, + }) + end + + -- Set up DAP if requested + local hasDap, dap = pcall(require, 'dap') + if M.options.load_dap_config and hasDap then + dap.adapters.jsonnet = { + type = 'executable', + command = M.options.jsonnet_debugger_bin, + args = M.options.jsonnet_debugger_args, + } + dap.configurations.jsonnet = { + { + type = 'jsonnet', + request = 'launch', + name = 'debug', + program = '${file}', + }, + } + end + + -- Register type with treesitter, for `edgy.nvim` support + vim.treesitter.language.register('json', filetype) +end + +M.setup = function(options) + vim.api.nvim_create_autocmd('FileType', { + pattern = { 'jsonnet' }, + callback = function(_) + do_setup(options) + + apply_mappings(M.options.keys) + apply_mappings(M.options.output_keys) + end, + }) + + vim.api.nvim_create_autocmd('FileType', { + pattern = { filetype }, + callback = function(_) + apply_mappings(M.options.output_keys) + end, + }) +end + +return M diff --git a/lua/nvim-jsonnet/job.lua b/lua/nvim-jsonnet/job.lua new file mode 100644 index 0000000..d95123b --- /dev/null +++ b/lua/nvim-jsonnet/job.lua @@ -0,0 +1,89 @@ +local Job = require('plenary.job') + +--- @class JobResult +--- @field stdout string The standard output +--- @field stderr string The standard error +--- @field code number The exit code + +--- Run a job synchronously and return the results +--- @param cmd string The command to run +--- @param args string[] The command arguments +--- @param cwd string? The working directory +--- @return JobResult +local function run_job_sync(cmd, args, cwd) + local stdout = {} + local stderr = {} + local result = {} + + Job:new({ + command = cmd, + args = args, + cwd = cwd, + on_stdout = function(_, line) + table.insert(stdout, line) + end, + on_stderr = function(_, line) + table.insert(stderr, line) + end, + on_exit = function(_, code) + result.code = code + end, + }):sync() + + result.stdout = table.concat(stdout, '\n') + result.stderr = table.concat(stderr, '\n') + return result +end + +--- @class SystemResult +--- @field stdout string The standard output +--- @field stderr string The standard error +--- @field code number The exit code + +--- Execute a system command and return the result +--- @param cmd string[] The command and arguments as a table +--- @param cwd string? The working directory +--- @return SystemResult +local function system(cmd, cwd) + local result = vim.system(cmd, { + text = true, + cwd = cwd, + }):wait() + + return { + stdout = result.stdout or '', + stderr = result.stderr or '', + code = result.code or -1, + } +end + +--- Get the filename from a path +--- @param path string The file path +--- @return string The filename +local function filename(path) + return vim.fs.basename(path) +end + +--- Get the directory name from a path +--- @param path string The file path +--- @return string The directory name +local function dirname(path) + local parts = vim.split(path, '/') + table.remove(parts) + return table.concat(parts, '/') +end + +--- Check if a buffer is valid +--- @param buffer_number number The buffer number +--- @return boolean True if the buffer is valid and loaded +local function is_valid_buffer(buffer_number) + return buffer_number and vim.api.nvim_buf_is_valid(buffer_number) and vim.api.nvim_buf_is_loaded(buffer_number) +end + +return { + run_job_sync = run_job_sync, + system = system, + filename = filename, + dirname = dirname, + is_valid_buffer = is_valid_buffer, +} diff --git a/lua/nvim-jsonnet/utils.lua b/lua/nvim-jsonnet/utils.lua new file mode 100644 index 0000000..639143e --- /dev/null +++ b/lua/nvim-jsonnet/utils.lua @@ -0,0 +1,4 @@ +return { + --- Convert a boolean-like string to a boolean value + stringtoboolean = { ['true'] = true, ['false'] = false }, +} diff --git a/screenshots/edgy.png b/screenshots/edgy.png new file mode 100644 index 0000000..a039997 Binary files /dev/null and b/screenshots/edgy.png differ diff --git a/screenshots/float.png b/screenshots/float.png new file mode 100644 index 0000000..b2d9070 Binary files /dev/null and b/screenshots/float.png differ