├── .envrc ├── .gitignore ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix └── lua └── table-nvim ├── config.lua ├── constants.lua ├── edit.lua ├── init.lua ├── keymaps.lua ├── md_table.lua ├── nav.lua └── utils.lua /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sachin Charakhwal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A markdown table editor 2 | A simple (for now?) markdown table editor that formats the table as you type. 3 | 4 | # Demo 5 | https://github.com/user-attachments/assets/b026dc0b-4f10-48cc-81cb-3edf0f3e7772 6 | 7 | # Dependencies 8 | - [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) 9 | - that's it! 10 | 11 | # Install 12 | 13 | Using [lazy.nvim](https://github.com/folke/lazy.nvim) 14 | ```lua 15 | { 16 | 'SCJangra/table-nvim', 17 | ft = 'markdown', 18 | opts = {}, 19 | } 20 | ``` 21 | 22 | # Default config 23 | ```lua 24 | { 25 | padd_column_separators = true, -- Insert a space around column separators. 26 | mappings = { -- next and prev work in Normal and Insert mode. All other mappings work in Normal mode. 27 | next = '', -- Go to next cell. 28 | prev = '', -- Go to previous cell. 29 | insert_row_up = '', -- Insert a row above the current row. 30 | insert_row_down = '', -- Insert a row below the current row. 31 | move_row_up = '', -- Move the current row up. 32 | move_row_down = '', -- Move the current row down. 33 | insert_column_left = '', -- Insert a column to the left of current column. 34 | insert_column_right = '', -- Insert a column to the right of current column. 35 | move_column_left = '', -- Move the current column to the left. 36 | move_column_right = '', -- Move the current column to the right. 37 | insert_table = '', -- Insert a new table. 38 | insert_table_alt = '', -- Insert a new table that is not surrounded by pipes. 39 | delete_column = '', -- Delete the column under cursor. 40 | } 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 0, 6 | "narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=", 7 | "path": "/nix/store/887hpp8a2i99n9jjwcvz6qkhhhqsvzkg-source", 8 | "type": "path" 9 | }, 10 | "original": { 11 | "id": "nixpkgs", 12 | "type": "indirect" 13 | } 14 | }, 15 | "root": { 16 | "inputs": { 17 | "nixpkgs": "nixpkgs" 18 | } 19 | } 20 | }, 21 | "root": "root", 22 | "version": 7 23 | } 24 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | outputs = { nixpkgs, ... }: 3 | let 4 | system = "x86_64-linux"; 5 | pkgs = import nixpkgs { inherit system; }; 6 | in 7 | { 8 | devShells.${system}.default = pkgs.mkShell { 9 | packages = with pkgs; [ lua-language-server nil ]; 10 | }; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /lua/table-nvim/config.lua: -------------------------------------------------------------------------------- 1 | ---@class (exact) TableNvimConfig Configuration of this plugin. 2 | ---@field padd_column_separators boolean Insert a space around column separators. 3 | ---@field mappings TableNvimMappings Keymappings. 4 | 5 | ---@class (exact) TableNvimMappings Keymappings used in this plugin. 6 | ---@field next string Go to next cell. 7 | ---@field prev string Go to previous cell. 8 | ---@field insert_row_up string Insert a row above the current row. 9 | ---@field insert_row_down string Insert a row below the current row. 10 | ---@field move_row_up string Move the current row up. 11 | ---@field move_row_down string Move the current row down. 12 | ---@field insert_column_left string Insert a column to the left of current column. 13 | ---@field insert_column_right string Insert a column to the right of current column. 14 | ---@field move_column_left string Move the current column to the left. 15 | ---@field move_column_right string Move the current column to the right. 16 | ---@field insert_table string Insert a new table. 17 | ---@field insert_table_alt string Insert a new table that is not surrounded by pipes. 18 | ---@field delete_column string Delete the column under cursor. 19 | 20 | ---@type TableNvimConfig 21 | local uconf = { 22 | padd_column_separators = true, -- Insert a space around column separators. 23 | mappings = { -- next and prev work in Normal and Insert mode. All other mappings work in Normal mode. 24 | next = '', -- Go to next cell. 25 | prev = '', -- Go to previous cell. 26 | insert_row_up = '', -- Insert a row above the current row. 27 | insert_row_down = '', -- Insert a row below the current row. 28 | move_row_up = '', -- Move the current row up. 29 | move_row_down = '', -- Move the current row down. 30 | insert_column_left = '', -- Insert a column to the left of current column. 31 | insert_column_right = '', -- Insert a column to the right of current column. 32 | move_column_left = '', -- Move the current column to the left. 33 | move_column_right = '', -- Move the current column to the right. 34 | insert_table = '', -- Insert a new table. 35 | insert_table_alt = '', -- Insert a new table that is not surrounded by pipes. 36 | delete_column = '', -- Delete the column under cursor. 37 | } 38 | } 39 | 40 | ---Configure the plugin. 41 | ---@param config TableNvimConfig 42 | local set_config = function(config) 43 | uconf = vim.tbl_deep_extend('force', uconf, config) 44 | end 45 | 46 | ---Returns the current configuration 47 | ---@return TableNvimConfig 48 | local get_config = function() 49 | return uconf 50 | end 51 | 52 | return { 53 | set_config = set_config, 54 | get_config = get_config 55 | } 56 | -------------------------------------------------------------------------------- /lua/table-nvim/constants.lua: -------------------------------------------------------------------------------- 1 | ---@class (exact) CellType Type of a table cell. 2 | 3 | ---@class (exact) ColAlign Alignment of a table column. 4 | 5 | return { 6 | ---@type CellType 7 | ---All cells in header and normal rows 8 | CELL_TEXT = {}, 9 | ---@type CellType 10 | ---A left aligned delimiter cell, like ':----'. 11 | ---@type CellType 12 | CELL_LEFT = {}, 13 | ---@type CellType 14 | ---A right aligned delimiter cell, like '----:'. 15 | CELL_RIGHT = {}, 16 | ---@type CellType 17 | ---A center aligned delimiter cell, like ':----:'. 18 | CELL_CENTER = {}, 19 | ---@type CellType 20 | ---A non aligned delimiter cell, like '----'. 21 | CELL_DELIMITER = {}, 22 | ---@type CellType 23 | ---A column separator, like '|'. 24 | CELL_PIPE = {}, 25 | 26 | ---@type ColAlign 27 | ---Align a column to the left. 28 | ALIGN_LEFT = {}, 29 | ---@type ColAlign 30 | ---Align a column to the right. 31 | ALIGN_RIGHT = {}, 32 | ---@type ColAlign 33 | ---Align a column to the center. 34 | ALIGN_CENTER = {}, 35 | ---@type ColAlign 36 | ---Do not align the column. 37 | ALIGN_NONE = {}, 38 | 39 | ---Index of the delimiter row in a table. 40 | DELIMITER_ROW = 2 41 | } 42 | -------------------------------------------------------------------------------- /lua/table-nvim/edit.lua: -------------------------------------------------------------------------------- 1 | local ts = vim.treesitter 2 | local api = vim.api 3 | 4 | local utils = require('table-nvim.utils') 5 | local MdTable = require('table-nvim.md_table') 6 | local C = require('table-nvim.constants') 7 | 8 | ---Add a new column to the table. 9 | ---@param left boolean If `true` the column is added to the left of current column, and to the right otherwise. 10 | local insert_column = function(left) 11 | local root = utils.get_tbl_root(ts.get_node()); 12 | if not root then return end 13 | 14 | local t = MdTable:new(root) 15 | 16 | if left then t:insert_column_left() else t:insert_column_right() end 17 | 18 | t:render() 19 | end 20 | 21 | local insert_column_left = function() insert_column(true) end 22 | local insert_column_right = function() insert_column(false) end 23 | 24 | ---Insert a new column to the table. 25 | ---@param up boolean If `true` the row is insert above current row, and below otherwise. 26 | local insert_row = function(up) 27 | local root = utils.get_tbl_root(ts.get_node()); 28 | if not root then return end 29 | 30 | local t = MdTable:new(root) 31 | 32 | local index = up and t:insert_row_up() or t:insert_row_down() 33 | 34 | t:render_row(index) 35 | end 36 | 37 | local insert_row_up = function() insert_row(true) end 38 | local insert_row_down = function() insert_row(false) end 39 | 40 | local insert_table = function() 41 | local row = api.nvim_win_get_cursor(0)[1] - 1 42 | local lines = utils.gen_table() 43 | api.nvim_buf_set_lines(0, row, row + 1, true, lines) 44 | end 45 | 46 | local insert_table_alt = function() 47 | local row = api.nvim_win_get_cursor(0)[1] - 1 48 | local lines = utils.gen_table_alt() 49 | api.nvim_buf_set_lines(0, row, row + 1, true, lines) 50 | end 51 | 52 | local delete_current_column = function() 53 | local root = utils.get_tbl_root(ts.get_node()); 54 | if not root then return end 55 | 56 | local t = MdTable:new(root) 57 | t:delete_current_column() 58 | t:render() 59 | end 60 | 61 | local move_column = function(left) 62 | local root = utils.get_tbl_root(ts.get_node()); 63 | if not root then return end 64 | 65 | local t = MdTable:new(root) 66 | local first = t.cursor_col 67 | local second = left and first - 1 or first + 1 68 | 69 | if left and second < 1 then return end 70 | if not left and second > #t.cols then return end 71 | 72 | vim.print(t.cursor_row, t.cursor_col) 73 | 74 | t:swap_columns(first, second) 75 | t:render() 76 | t:move_cursor_to(t.cursor_row, second) 77 | end 78 | 79 | local move_column_left = function() move_column(true) end 80 | local move_column_right = function() move_column(false) end 81 | 82 | local move_row = function(up) 83 | local root = utils.get_tbl_root(ts.get_node()); 84 | if not root then return end 85 | 86 | local start = root:start() + C.DELIMITER_ROW 87 | 88 | -- Minus 1 is because the end row index is one larger than the final row in the table. 89 | local end_ = root:end_() - 1 90 | 91 | local cursor = api.nvim_win_get_cursor(0) 92 | 93 | -- Minus 1 to convert row index to 0-based indexing. 94 | local cursor_row = cursor[1] - 1 95 | 96 | if up and cursor_row <= start then return end 97 | if not up and cursor_row >= end_ or cursor_row < start then return end 98 | 99 | local second_row = up and cursor_row - 1 or cursor_row + 1 100 | 101 | local first, second 102 | if up then first, second = second_row, cursor_row else first, second = cursor_row, second_row end 103 | 104 | -- Plus 1 because indexes are end-exclusive 105 | local lines = api.nvim_buf_get_lines(0, first, second + 1, true) 106 | lines[1], lines[2] = lines[2], lines[1] 107 | 108 | -- Plus 1 because indexes are end-exclusive 109 | api.nvim_buf_set_lines(0, first, second + 1, true, lines) 110 | 111 | -- Plus 1 to convert to 1-based indexing. 112 | api.nvim_win_set_cursor(0, { second_row + 1, cursor[2] }) 113 | end 114 | 115 | local move_row_up = function() move_row(true) end 116 | local move_row_down = function() move_row(false) end 117 | 118 | return { 119 | insert_row_up = insert_row_up, 120 | insert_row_down = insert_row_down, 121 | 122 | move_row_up = move_row_up, 123 | move_row_down = move_row_down, 124 | 125 | insert_column_left = insert_column_left, 126 | insert_column_right = insert_column_right, 127 | 128 | move_column_left = move_column_left, 129 | move_column_right = move_column_right, 130 | 131 | insert_table = insert_table, 132 | insert_table_alt = insert_table_alt, 133 | 134 | delete_current_column = delete_current_column, 135 | } 136 | -------------------------------------------------------------------------------- /lua/table-nvim/init.lua: -------------------------------------------------------------------------------- 1 | local utils = require('table-nvim.utils') 2 | local MdTable = require('table-nvim.md_table') 3 | local conf = require('table-nvim.config') 4 | local maps = require('table-nvim.keymaps') 5 | 6 | local api = vim.api 7 | local ts = vim.treesitter 8 | 9 | local group_id = api.nvim_create_augroup('table-nvim', { clear = true }) 10 | 11 | api.nvim_create_autocmd({ 'InsertLeave' }, { 12 | group = group_id, 13 | pattern = { '*.md', '*.mdx' }, 14 | callback = function() 15 | local root = utils.get_tbl_root(ts.get_node()); 16 | if not root then return end 17 | 18 | MdTable:new(root):render() 19 | end 20 | }) 21 | 22 | api.nvim_create_autocmd({ 'BufEnter' }, { 23 | group = group_id, 24 | pattern = { '*.md', '*.mdx' }, 25 | callback = function(opts) maps.set_keymaps(opts.buf) end 26 | }) 27 | 28 | ---Setup the plugin. 29 | ---@param config TableNvimConfig 30 | local setup = function(config) 31 | conf.set_config(config) 32 | end 33 | 34 | return { 35 | setup = setup 36 | } 37 | -------------------------------------------------------------------------------- /lua/table-nvim/keymaps.lua: -------------------------------------------------------------------------------- 1 | local map = vim.keymap.set 2 | 3 | local conf = require('table-nvim.config') 4 | local nav = require('table-nvim.nav') 5 | local edit = require('table-nvim.edit') 6 | 7 | ---Setup keymaps 8 | ---@param buf number Buffer number. 9 | local set_keymaps = function(buf) 10 | local maps = conf.get_config().mappings 11 | 12 | local function opts(desc) 13 | return { noremap = true, buffer = buf, desc = desc } 14 | end 15 | local function opts_expr(desc) 16 | return { noremap = true, buffer = buf, expr = true, desc = desc } 17 | end 18 | 19 | map({ 'n', 'i' }, maps.next, nav.next, opts_expr('Go to next cell')) 20 | map({ 'n', 'i' }, maps.prev, nav.prev, opts_expr('Go to previous cell')) 21 | map('n', maps.insert_row_up, edit.insert_row_up, opts('Insert row up')) 22 | map('n', maps.insert_row_down, edit.insert_row_down, opts('Insert row down')) 23 | map('n', maps.move_row_up, edit.move_row_up, opts('Move row up')) 24 | map('n', maps.move_row_down, edit.move_row_down, opts('Move row down')) 25 | map('n', maps.insert_column_left, edit.insert_column_left, opts('Insert column left')) 26 | map('n', maps.insert_column_right, edit.insert_column_right, opts('Insert column right')) 27 | map('n', maps.move_column_left, edit.move_column_left, opts('Move column left')) 28 | map('n', maps.move_column_right, edit.move_column_right, opts('Move column right')) 29 | map('n', maps.insert_table, edit.insert_table, opts('Insert table')) 30 | map('n', maps.insert_table_alt, edit.insert_table_alt, opts('Insert table (no outline)')) 31 | map('n', maps.delete_column, edit.delete_current_column, opts('Delete column')) 32 | end 33 | 34 | return { 35 | set_keymaps = set_keymaps 36 | } 37 | -------------------------------------------------------------------------------- /lua/table-nvim/md_table.lua: -------------------------------------------------------------------------------- 1 | local C = require('table-nvim.constants') 2 | local utils = require('table-nvim.utils') 3 | local conf = require('table-nvim.config') 4 | 5 | local api, ts, fn = vim.api, vim.treesitter, vim.fn 6 | 7 | ---@class (exact) MdTableCell 8 | ---@field type CellType The type of this cell. 9 | ---@field text string Text of this cell. 10 | 11 | ---@class (exact) MdTableColInfo Information about a column in the table, including the delimiter (`|`) columns. 12 | ---@field is_delimiter boolean Is this a delimiter column. 13 | ---@field max_width number Text width of the column. 14 | ---@field alighment ColAlign How to align this column. 15 | 16 | ---@class (exact) MdTable Information about a markdown table. 17 | ---@field rows MdTableCell[][] Rows in this table. 18 | ---@field start number Index of the first row in the table. 19 | ---@field end_ number Index of the last row in the table. 20 | ---@field indent number Indentation of the table. 21 | ---@field cursor_col number The current column position of the cursor. 22 | ---@field cursor_row number The currow row position of the cursor. 23 | ---@field cols MdTableColInfo[] Information about all columns in the table. 24 | ---@field root TSNode The root node of the table. 25 | ---@field pipes boolean Whether the table is surrounded by pipes. 26 | local MdTable = {} 27 | 28 | ---@param root TSNode The root node of a table. 29 | ---@return MdTable 30 | function MdTable:new(root) 31 | assert(utils.is_tbl_root(root), 'not a table root node') 32 | 33 | local cursor_pos = api.nvim_win_get_cursor(0) 34 | local cursor_row, cursor_col = cursor_pos[1] - 1, cursor_pos[2] 35 | 36 | local start = root:start(); 37 | local end_ = root:end_(); 38 | local indent 39 | local cols = {} 40 | local rows = {} 41 | local cursor_col_index = nil 42 | local cursor_row_index = nil 43 | local pipes = false 44 | 45 | for r, row in utils.iter_named_children(root) do 46 | rows[r] = {} 47 | 48 | if r == 1 then 49 | local col = row:child(0) 50 | 51 | if col then 52 | _, indent = col:start() 53 | if col:type() == '|' then pipes = true end 54 | end 55 | end 56 | 57 | for c, col in utils.iter_named_children(row) do 58 | cols[c] = cols[c] or {} 59 | 60 | local text = ts.get_node_text(col, 0):match('^%s*(.-)%s*$') 61 | local width = fn.strwidth(text) 62 | local type = self:cell_type(text) 63 | 64 | -- Set the current column position of the cursor in the table. 65 | local end_row, end_col = col:end_() 66 | if not cursor_col_index and cursor_row == end_row and cursor_col < end_col then 67 | cursor_row_index, cursor_col_index = r, c 68 | end 69 | 70 | if r == 1 then 71 | cols[c].max_width = width 72 | elseif r == C.DELIMITER_ROW then 73 | if type == C.CELL_LEFT then 74 | cols[c].alighment = C.ALIGN_LEFT 75 | elseif type == C.CELL_RIGHT then 76 | cols[c].alighment = C.ALIGN_RIGHT 77 | elseif type == C.CELL_CENTER then 78 | cols[c].alighment = C.ALIGN_CENTER 79 | elseif type == C.CELL_DELIMITER then 80 | cols[c].alighment = C.ALIGN_NONE 81 | end 82 | else 83 | cols[c].max_width = math.max(width, cols[c].max_width or 0) 84 | end 85 | 86 | rows[r][c] = { type = type, text = text } 87 | end 88 | end 89 | 90 | ---@type MdTable 91 | local m = { 92 | start = start, 93 | end_ = end_, 94 | indent = indent, 95 | cols = cols, 96 | rows = rows, 97 | cursor_col = cursor_col_index or #cols, 98 | cursor_row = cursor_row_index or #rows, 99 | root = root, 100 | pipes = pipes, 101 | } 102 | 103 | ---@diagnostic disable-next-line: inject-field 104 | self.__index = self 105 | return setmetatable(m, self) 106 | end 107 | 108 | ---Generate an array of formatted rows. 109 | ---@return string[] 110 | function MdTable:generate_rows() 111 | local lines = {} 112 | 113 | for index = 1, #self.rows do lines[index] = self:generate_row(index) end 114 | 115 | return lines 116 | end 117 | 118 | ---Generate a single formatted row. 119 | ---@param index number The index of the row to generate. 120 | ---@return string 121 | function MdTable:generate_row(index) 122 | local padd = conf.get_config().padd_column_separators 123 | local line = {} 124 | local row = self.rows[index] 125 | 126 | table.insert(line, string.rep(' ', self.indent)) 127 | 128 | if self.pipes then 129 | local del = padd and '| ' or '|' 130 | table.insert(line, del) 131 | end 132 | 133 | local cell = row[1] 134 | table.insert(line, self:cell_text(cell.type, cell.text, 1)) 135 | 136 | local len = #row 137 | 138 | for c = 2, len do 139 | cell = row[c] 140 | local del = padd and ' | ' or '|' 141 | 142 | table.insert(line, del) 143 | table.insert(line, self:cell_text(cell.type, cell.text, c)) 144 | end 145 | 146 | if self.pipes then 147 | local del = padd and ' |' or '|' 148 | table.insert(line, del) 149 | end 150 | 151 | return table.concat(line) 152 | end 153 | 154 | ---Get the type of cell for a given string. 155 | ---@param text string 156 | ---@return CellType 157 | function MdTable:cell_type(text) 158 | if text:match('^:%-+$') then return C.CELL_LEFT end 159 | if text:match('^%-+:$') then return C.CELL_RIGHT end 160 | if text:match('^:%-+:$') then return C.CELL_CENTER end 161 | if text:match('^%-+$') then return C.CELL_DELIMITER end 162 | 163 | return C.CELL_TEXT 164 | end 165 | 166 | ---Format the cell text and return the formatted text. 167 | ---@param type CellType Type of this cell. 168 | ---@param text string Text of the cell. 169 | ---@param cell number Cell index in the row. 170 | ---@return string 171 | function MdTable:cell_text(type, text, cell) 172 | if type == C.CELL_LEFT then 173 | local hyphens = string.rep('-', self.cols[cell].max_width - 1) 174 | return ':' .. hyphens 175 | end 176 | 177 | if type == C.CELL_RIGHT then 178 | local hyphens = string.rep('-', self.cols[cell].max_width - 1) 179 | return hyphens .. ':' 180 | end 181 | 182 | if type == C.CELL_CENTER then 183 | local hyphens = string.rep('-', self.cols[cell].max_width - 2) 184 | return ':' .. hyphens .. ':' 185 | end 186 | 187 | if type == C.CELL_DELIMITER then 188 | return string.rep('-', self.cols[cell].max_width) 189 | end 190 | 191 | local col = self.cols[cell] 192 | 193 | if col.alighment == C.ALIGN_LEFT or col.alighment == C.ALIGN_NONE then 194 | local padding = col.max_width - fn.strwidth(text) 195 | return text .. string.rep(' ', padding) 196 | end 197 | 198 | if col.alighment == C.ALIGN_RIGHT then 199 | local padding = col.max_width - fn.strwidth(text) 200 | return string.rep(' ', padding) .. text 201 | end 202 | 203 | local max_width = self.cols[cell].max_width 204 | local padding = max_width - fn.strwidth(text) 205 | local left_padding = math.floor(padding / 2) 206 | local right_padding = padding - left_padding 207 | 208 | return table.concat({ string.rep(' ', left_padding), text, string.rep(' ', right_padding) }) 209 | end 210 | 211 | ---Extend a row (to a given length) by inserting new cells at the end. 212 | ---@param row MdTableCell[] The row to extend. 213 | ---@param len number The length to extend to. 214 | function MdTable:extend_row_to(row, len) 215 | for index = #row + 1, len do 216 | row[index] = { type = C.CELL_TEXT, text = ' ' } 217 | end 218 | end 219 | 220 | ---Generate a new cell for the given row and column index. 221 | ---@param row_index number 222 | ---@param col_index number 223 | ---@return MdTableCell 224 | function MdTable:gen_cell_for(row_index, col_index) 225 | local cell_delimiter = { text = '-', type = C.CELL_DELIMITER } 226 | local cell_x = { text = 'x', type = C.CELL_TEXT } 227 | local cell_space = { text = ' ', type = C.CELL_TEXT } 228 | 229 | if row_index == C.DELIMITER_ROW then return cell_delimiter end 230 | if row_index == 1 or col_index == 1 or col_index == #self.cols + 1 then return cell_x end 231 | return cell_space 232 | end 233 | 234 | ---Insert a column to the table at the given index. 235 | ---@param index number The index at which to add the column. 236 | function MdTable:insert_column_at(index) 237 | for i, row in ipairs(self.rows) do 238 | self:extend_row_to(row, index - 1) 239 | 240 | local cell = self:gen_cell_for(i, index) 241 | 242 | table.insert(row, index, cell) 243 | end 244 | 245 | local cell = self:gen_cell_for(1, index) 246 | 247 | local col_info = { max_width = fn.strwidth(cell.text), alighment = C.ALIGN_NONE } 248 | 249 | table.insert(self.cols, index, col_info) 250 | end 251 | 252 | ---Insert a column to the left of current column. 253 | function MdTable:insert_column_left() 254 | self:insert_column_at(self.cursor_col) 255 | end 256 | 257 | ---Insert a column to the left of current column. 258 | function MdTable:insert_column_right() 259 | self:insert_column_at(self.cursor_col + 1) 260 | end 261 | 262 | ---Insert a row at the given index. 263 | ---@param index number The index at which to insert the column. 264 | function MdTable:insert_row_at(index) 265 | if index <= C.DELIMITER_ROW then index = C.DELIMITER_ROW + 1 end 266 | 267 | local row = {} 268 | 269 | local col_count = #self.cols 270 | local cell_space = { type = C.CELL_TEXT, text = ' ' } 271 | local cell_x = { type = C.CELL_TEXT, text = 'x' } 272 | 273 | row[1] = cell_x 274 | 275 | for c = 2, col_count - 1 do 276 | row[c] = cell_space 277 | end 278 | 279 | row[col_count] = cell_x 280 | 281 | table.insert(self.rows, index, row) 282 | 283 | return index 284 | end 285 | 286 | ---Insert a row above the current row. 287 | function MdTable:insert_row_up() 288 | return self:insert_row_at(self.cursor_row) 289 | end 290 | 291 | ---Insert a row below the current row. 292 | function MdTable:insert_row_down() 293 | return self:insert_row_at(self.cursor_row + 1) 294 | end 295 | 296 | ---Render the table to the buffer. 297 | function MdTable:render() 298 | if vim.tbl_isempty(self.cols) then 299 | api.nvim_buf_set_lines(0, self.start, self.end_, true, {}) 300 | return 301 | end 302 | 303 | local rows = self:generate_rows() 304 | api.nvim_buf_set_lines(0, self.start, self.end_, true, rows) 305 | end 306 | 307 | ---Render a specific row (likely a newly inserted row) to the table. 308 | ---This will not replace an existing row, but insert a new one at the given index. 309 | ---@param index number The row index to render. 310 | function MdTable:render_row(index) 311 | local row = self:generate_row(index) 312 | local r = self.start + index - 1 313 | api.nvim_buf_set_lines(0, r, r, true, { row }) 314 | end 315 | 316 | ---Delete the column under cursor. 317 | function MdTable:delete_current_column() 318 | local index = self.cursor_col 319 | local len = #self.cols 320 | 321 | for _, row in ipairs(self.rows) do table.remove(row, index) end 322 | for i = index, len do self.cols[i] = self.cols[i + 1] end 323 | end 324 | 325 | ---Swap two columns in the table. 326 | ---@param first number Index of the first column. 327 | ---@param second number Index of the second column. 328 | function MdTable:swap_columns(first, second) 329 | assert(first <= #self.cols and first > 0, 'first column index is out of bounds') 330 | assert(second <= #self.cols and second > 0, 'second column index is out of bounds') 331 | 332 | for _, row in ipairs(self.rows) do row[first], row[second] = row[second], row[first] end 333 | 334 | self.cols[first], self.cols[second] = self.cols[second], self.cols[first] 335 | end 336 | 337 | ---Swap two rows in the table. 338 | ---@param first number Index of the first row. 339 | ---@param second number Index of the second row. 340 | function MdTable:swap_rows(first, second) 341 | assert(first <= #self.rows and first > 0, 'first row index is out of bounds') 342 | assert(second <= #self.rows and second > 0, 'second row index is out of bounds') 343 | 344 | self.rows[first], self.rows[second] = self.rows[second], self.rows[first] 345 | end 346 | 347 | ---Move the cursor to a specific cell. 348 | ---@param row number Row index of the cell. 349 | ---@param col number Column index of the cell. 350 | function MdTable:move_cursor_to(row, col) 351 | local padd = conf.get_config().padd_column_separators 352 | 353 | local c = 0 354 | 355 | if self.pipes then c = padd and 2 or 1 end 356 | 357 | for i = 1, col - 1 do 358 | c = c + self.cols[i].max_width 359 | c = padd and c + 3 or c + 1 360 | end 361 | 362 | local r = self.start + row 363 | 364 | api.nvim_win_set_cursor(0, { r, c }) 365 | end 366 | 367 | return MdTable 368 | -------------------------------------------------------------------------------- /lua/table-nvim/nav.lua: -------------------------------------------------------------------------------- 1 | local ts = vim.treesitter 2 | local api = vim.api 3 | 4 | local utils = require('table-nvim.utils') 5 | local conf = require('table-nvim.config') 6 | 7 | ---Returns next or previous named sibling. 8 | ---@param node TSNode The node for which to get the sibling. 9 | ---@param next boolean Whether to return next or previous sibling. 10 | ---@return TSNode? 11 | local get_named_sibling = function(node, next) 12 | if next then 13 | return node:next_named_sibling() 14 | else 15 | return node:prev_named_sibling() 16 | end 17 | end 18 | 19 | ---Get next or previous node to the current node. 20 | ---@param node TSNode Current node. 21 | ---@param row number Row index of the cursor. 22 | ---@param col number Column index of the cursor. 23 | ---@param next boolean Whether to get the next or previous node. 24 | ---@return TSNode 25 | local get_node = function(node, row, col, next) 26 | local root = utils.get_tbl_root(node) 27 | if not root then return node end 28 | 29 | if utils.is_tbl_align(node) then node = node:parent() or node end 30 | 31 | local cell = get_named_sibling(node, next) 32 | 33 | if not cell then 34 | local edge_column = utils.is_tbl_cell(node) 35 | local edge_row = not ts.is_in_node_range(root, next and row + 1 or row - 1, col) 36 | 37 | if edge_column and edge_row then 38 | local row_count = root:named_child_count() 39 | if row_count == 0 then return node end 40 | 41 | local next_row = next and root:named_child(0) or root:named_child(row_count - 1) 42 | if not next_row then return node end 43 | 44 | local col_count = next_row:named_child_count() 45 | if col_count == 0 then return node end 46 | 47 | local next_col = next and next_row:named_child(0) or next_row:named_child(col_count - 1) 48 | return next_col or node 49 | elseif edge_column then 50 | local parent_row = node:parent() 51 | if not parent_row then return node end 52 | 53 | local next_row = next and parent_row:next_named_sibling() or parent_row:prev_named_sibling() 54 | if not next_row then return node end 55 | 56 | local col_count = next_row:named_child_count() 57 | if col_count == 0 then return node end 58 | 59 | local next_col = next and next_row:named_child(0) or next_row:named_child(col_count - 1) 60 | return next_col or node 61 | elseif edge_row then 62 | for c in node:iter_children() do 63 | if ts.is_in_node_range(c, row, col) then 64 | return next and c:next_named_sibling() or c:prev_named_sibling() or node 65 | end 66 | end 67 | end 68 | 69 | return node 70 | end 71 | 72 | local r = cell:start() 73 | 74 | if row == r then return cell end 75 | 76 | for c in node:iter_children() do 77 | if ts.is_in_node_range(c, row, col) then 78 | local next_cell = get_named_sibling(c, next) 79 | if next_cell then return next_cell end 80 | 81 | local col_count = cell:named_child_count() 82 | if col_count == 0 then return node end 83 | 84 | next_cell = next and cell:named_child(0) or cell:named_child(col_count - 1) 85 | return next_cell or node 86 | end 87 | end 88 | 89 | return node 90 | end 91 | 92 | ---Move to next or previous node. 93 | ---@param next boolean Whether to move to next or previous node. 94 | local move = function(next) 95 | local pos = api.nvim_win_get_cursor(0) 96 | pos[1] = pos[1] - 1 -- Change to 0 based indexing. 97 | 98 | local node = ts.get_node { pos = pos } 99 | if not node or not utils.is_tbl_node(node) then return end 100 | 101 | local cell = get_node(node, pos[1], pos[2], next) 102 | if not cell then return end 103 | 104 | local row, col = cell:start() 105 | 106 | api.nvim_win_set_cursor(0, { row + 1, col }) 107 | end 108 | 109 | local next = function() 110 | local node = ts.get_node() 111 | 112 | if not node or not utils.is_tbl_node(node) then 113 | return conf.get_config().mappings.next 114 | else 115 | return 'lua require("table-nvim.nav").move(true)' 116 | end 117 | end 118 | 119 | local prev = function() 120 | local node = ts.get_node() 121 | 122 | if not node or not utils.is_tbl_node(node) then 123 | return conf.get_config().mappings.prev 124 | else 125 | return 'lua require("table-nvim.nav").move(false)' 126 | end 127 | end 128 | 129 | return { 130 | next = next, 131 | prev = prev, 132 | move = move, 133 | } 134 | -------------------------------------------------------------------------------- /lua/table-nvim/utils.lua: -------------------------------------------------------------------------------- 1 | local tbl_node = 'pipe_table' 2 | local tbl_cell = 'pipe_table_cell' 3 | local tbl_delimiter_cell = 'pipe_table_delimiter_cell' 4 | local tbl_align_left = 'pipe_table_align_left' 5 | local tbl_align_right = 'pipe_table_align_right' 6 | local tbl_node_len = #tbl_node 7 | 8 | local conf = require('table-nvim.config') 9 | 10 | ---Returns `true` if the node is the root of a markdown table and `false` otherwise. 11 | ---@param node TSNode The node to check. 12 | local is_tbl_root = function(node) 13 | return node:type() == tbl_node 14 | end 15 | 16 | ---Returns `true` if the node belongs to a markdown table and `false` otherwise. 17 | ---@param node TSNode The node to check. 18 | local is_tbl_node = function(node) 19 | return string.sub(node:type(), 1, tbl_node_len) == tbl_node 20 | end 21 | 22 | ---@param node TSNode? A node within a markdown table 23 | ---@return TSNode? tbl_root Root node of a markdown table, if the `node` does not belong to a markdown table, then `nil` is returned 24 | local get_tbl_root = function(node) 25 | if node == nil then return nil end 26 | if string.sub(node:type(), 1, tbl_node_len) ~= tbl_node then return nil end 27 | 28 | if is_tbl_root(node) then return node end 29 | 30 | while true do 31 | node = node:parent() 32 | if node == nil then return nil end 33 | if is_tbl_root(node) then return node end 34 | end 35 | end 36 | 37 | ---Returns `true` if the provided node is a table cell, and `false` otherwise. 38 | local is_tbl_cell = function(node) 39 | local type = node:type() 40 | return type == tbl_cell or type == tbl_delimiter_cell 41 | end 42 | 43 | ---Returns `true` if the provided node is an alignment node, and `false` otherwise. 44 | local is_tbl_align = function(node) 45 | local type = node:type() 46 | return type == tbl_align_left or type == tbl_align_right 47 | end 48 | 49 | ---Returns rows for a new table that is not surrounded by pipes. 50 | ---@return string[] 51 | local gen_table_alt = function() 52 | local padd = conf.get_config().padd_column_separators 53 | local column_separator = padd and ' | ' or '|' 54 | 55 | local header_row = { 'Column1', column_separator, 'Column2' } 56 | local delimiter_row = { '-------', column_separator, '-------' } 57 | local row = { 'x ', column_separator, 'x' } 58 | 59 | return { 60 | table.concat(header_row), 61 | table.concat(delimiter_row), 62 | table.concat(row), 63 | } 64 | end 65 | 66 | ---Returns rows for a new table. 67 | ---@return string[] 68 | local gen_table = function() 69 | local padd = conf.get_config().padd_column_separators 70 | local first_separator = padd and '| ' or '|' 71 | local last_separator = padd and ' |' or '|' 72 | local separator = padd and ' | ' or '|' 73 | 74 | local header_row = { first_separator, 'Column1', separator, 'Column2', last_separator } 75 | local delimiter_row = { first_separator, '-------', separator, '-------', last_separator } 76 | local row = { first_separator, 'x ', separator, 'x ', last_separator } 77 | 78 | return { 79 | table.concat(header_row), 80 | table.concat(delimiter_row), 81 | table.concat(row), 82 | } 83 | end 84 | 85 | ---Iterate of all children of a treesitter node. 86 | ---@param node TSNode 87 | ---@return fun(): integer?, TSNode? 88 | local iter_children = function(node) 89 | local n = node:child_count() 90 | local i = -1 91 | return function() 92 | i = i + 1 93 | if i < n then return i + 1, node:child(i) end 94 | end 95 | end 96 | 97 | ---Iterate of all named children of a treesitter node. 98 | ---@param node TSNode 99 | ---@return fun(): integer?, TSNode? 100 | local iter_named_children = function(node) 101 | local n = node:named_child_count() 102 | local i = -1 103 | return function() 104 | i = i + 1 105 | if i < n then return i + 1, node:named_child(i) end 106 | end 107 | end 108 | 109 | return { 110 | get_tbl_root = get_tbl_root, 111 | is_tbl_root = is_tbl_root, 112 | is_tbl_node = is_tbl_node, 113 | is_tbl_cell = is_tbl_cell, 114 | is_tbl_align = is_tbl_align, 115 | gen_table = gen_table, 116 | gen_table_alt = gen_table_alt, 117 | iter_children = iter_children, 118 | iter_named_children = iter_named_children, 119 | } 120 | --------------------------------------------------------------------------------