feat: initial commit
This commit is contained in:
commit
e2d177b480
3
.editorconfig
Normal file
3
.editorconfig
Normal file
@ -0,0 +1,3 @@
|
||||
[*.lua]
|
||||
indent_style = space
|
||||
indent_size = 2
|
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Compiled Lua sources
|
||||
luac.out
|
||||
|
||||
# luarocks build files
|
||||
*.src.rock
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# Object files
|
||||
*.o
|
||||
*.os
|
||||
*.ko
|
||||
*.obj
|
||||
*.elf
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Libraries
|
||||
*.lib
|
||||
*.a
|
||||
*.la
|
||||
*.lo
|
||||
*.def
|
||||
*.exp
|
||||
|
||||
# Shared objects (inc. Windows DLLs)
|
||||
*.dll
|
||||
*.so
|
||||
*.so.*
|
||||
*.dylib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
*.i*86
|
||||
*.x86_64
|
||||
*.hex
|
||||
|
||||
# Directory containing testing dependencies downloaded during test run
|
||||
.deps/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT Licence
|
||||
|
||||
Copyright (c) 2024 Price Hiller
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
8
Makefile
Normal file
8
Makefile
Normal file
@ -0,0 +1,8 @@
|
||||
clean:
|
||||
nvim --headless --clean -n -c "lua vim.fn.delete('./tests/.deps', 'rf')" +q
|
||||
test:
|
||||
nvim --headless --clean -u tests/test.lua "$(FILE)"
|
||||
lint:
|
||||
stylua --check lua/ tests/
|
||||
format:
|
||||
stylua lua/ tests/
|
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Virt-Indent.nvim
|
||||
|
||||
Add virtual indentation to align content under headlines to their headings.
|
||||
|
||||
## Markdown Showcase
|
||||
|
||||
_Before:_
|
||||
![Before](./assets/before.png)
|
||||
|
||||
_After:_
|
||||
![After](./assets/after.png)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
- Neovim 0.10.0 or later
|
||||
|
||||
### Installation
|
||||
|
||||
- [**lazy.nvim**](https://github.com/folke/lazy.nvim)
|
||||
```lua
|
||||
{
|
||||
"PriceHiller/Virt-Indent.nvim",
|
||||
ft = { "org", "markdown" },
|
||||
}
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin is a module extracted from [nvim-orgmode](https://github.com/nvim-orgmode/orgmode/), specifically its [`virtual-indent`](https://github.com/nvim-orgmode/orgmode/blob/master/lua/orgmode/ui/virtual_indent.lua) module and adds additional support for more filetypes beyond org files.
|
||||
|
||||
- [@danilshvalov](https://github.com/danilshvalov), the original creator of some of this code. I ultimately took the existing code he wrote and carried a PR with my own additions to completion and integration into nvim-orgmode.
|
||||
- [@kristijanhusak](https://github.com/kristijanhusak), the creator/maintainer of nvim-orgmode. He has done an amazing amount of work on that plugin and much of what was done in this plugin would not have come into being without him.
|
BIN
assets/after.png
Normal file
BIN
assets/after.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
BIN
assets/before.png
Normal file
BIN
assets/before.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
1
ftplugin/markdown.lua
Normal file
1
ftplugin/markdown.lua
Normal file
@ -0,0 +1 @@
|
||||
require("vindent").ftplugin_setup()
|
1
ftplugin/org.lua
Normal file
1
ftplugin/org.lua
Normal file
@ -0,0 +1 @@
|
||||
require("vindent").ftplugin_setup()
|
178
lua/vindent/indent.lua
Normal file
178
lua/vindent/indent.lua
Normal file
@ -0,0 +1,178 @@
|
||||
---@class VirtualIndent
|
||||
---@field private _ns_id number extmarks namespace id
|
||||
---@field private _bufnr integer Buffer VirtualIndent is attached to
|
||||
---@field private _attached boolean Whether or not VirtualIndent is attached for its buffer
|
||||
---@field private _bufnrs table<integer, VirtualIndent> Buffers with VirtualIndent attached
|
||||
---@field private _watcher_running boolean Whether or not VirtualIndent is reacting to `vim.b.org_indent_mode`
|
||||
---@field private _tree_utils table<string, function> Treesitter utilities for the given filetype
|
||||
---@field private _fallback_pattern string Pattern to search for if treesitter parser fails
|
||||
local VirtualIndent = {
|
||||
_ns_id = vim.api.nvim_create_namespace "VirtIndent",
|
||||
_bufnrs = {},
|
||||
}
|
||||
VirtualIndent.__index = VirtualIndent
|
||||
|
||||
--- Creates a new instance of VirtualIndent for a given buffer or returns the existing instance if
|
||||
--- one exists
|
||||
---@param bufnr? integer Buffer to use for VirtualIndent when attached
|
||||
---@return VirtualIndent?
|
||||
function VirtualIndent:new(bufnr)
|
||||
-- TODO: Improve organization of this, ideally should be a separate module that returns these.
|
||||
local ft_settings = {
|
||||
markdown = {
|
||||
utils = require "vindent.treesitter.markdown",
|
||||
fallback_pattern = "^%#+",
|
||||
},
|
||||
org = {
|
||||
utils = require "vindent.treesitter.org",
|
||||
fallback_pattern = "^%*+",
|
||||
},
|
||||
}
|
||||
|
||||
local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) or ""
|
||||
local ft_setting = ft_settings[filetype]
|
||||
if not ft_setting then
|
||||
return
|
||||
end
|
||||
|
||||
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||
if self._bufnrs[bufnr] then
|
||||
return self._bufnrs[bufnr]
|
||||
end
|
||||
|
||||
local this = setmetatable({
|
||||
_bufnr = bufnr,
|
||||
_watcher_running = false,
|
||||
_attached = false,
|
||||
_fallback_pattern = ft_setting.fallback_pattern,
|
||||
_tree_utils = ft_setting.utils,
|
||||
}, self)
|
||||
self._bufnrs[bufnr] = this
|
||||
return this
|
||||
end
|
||||
|
||||
function VirtualIndent:_delete_old_extmarks(start_line, end_line)
|
||||
local ok, old_extmarks = pcall(
|
||||
vim.api.nvim_buf_get_extmarks,
|
||||
self._bufnr,
|
||||
self._ns_id,
|
||||
{ start_line, 0 },
|
||||
{ end_line, 0 },
|
||||
{ type = "virt_text" }
|
||||
)
|
||||
if not ok then
|
||||
old_extmarks = {}
|
||||
end
|
||||
for _, ext in ipairs(old_extmarks) do
|
||||
vim.api.nvim_buf_del_extmark(self._bufnr, self._ns_id, ext[1])
|
||||
end
|
||||
end
|
||||
|
||||
function VirtualIndent:_get_indent_size(line, ts_has_errors, ts_fallback_pat)
|
||||
-- If tree has errors, we can't rely on treesitter to get the correct indentation
|
||||
-- Fallback to searching closest headline by checking each previous line
|
||||
if ts_has_errors then
|
||||
local linenr = line
|
||||
while linenr > 0 do
|
||||
local _, level = vim.fn.getline(linenr):find(ts_fallback_pat)
|
||||
if level then
|
||||
-- If the current line is a headline we should return no virtual indentation, otherwise
|
||||
-- return virtual indentation
|
||||
return (linenr == line and 0 or level + 1)
|
||||
end
|
||||
linenr = linenr - 1
|
||||
end
|
||||
end
|
||||
|
||||
local headline = self._tree_utils.closest_headline_node { line + 1, 1 }
|
||||
|
||||
if headline then
|
||||
local headline_line = headline:start()
|
||||
|
||||
if headline_line ~= line then
|
||||
return self._tree_utils.headline_level(headline) + 1
|
||||
end
|
||||
end
|
||||
|
||||
return 0
|
||||
end
|
||||
|
||||
---@param start_line number start line number to set the indentation, 0-based inclusive
|
||||
---@param end_line number end line number to set the indentation, 0-based inclusive
|
||||
---@param ignore_ts? boolean whether or not to skip the treesitter start & end lookup
|
||||
function VirtualIndent:set_indent(start_line, end_line, ignore_ts)
|
||||
ignore_ts = ignore_ts or false
|
||||
local headline = self._tree_utils.closest_headline_node { start_line + 1, 1 }
|
||||
if headline and not ignore_ts then
|
||||
local parent = headline:parent()
|
||||
if parent then
|
||||
start_line = math.min(parent:start(), start_line)
|
||||
end_line = math.max(parent:end_(), end_line)
|
||||
end
|
||||
end
|
||||
if start_line > 0 then
|
||||
start_line = start_line - 1
|
||||
end
|
||||
|
||||
local node_at_cursor = vim.treesitter.get_node()
|
||||
local ts_has_errors = false
|
||||
if node_at_cursor then
|
||||
ts_has_errors = node_at_cursor:tree():root():has_error()
|
||||
end
|
||||
|
||||
self:_delete_old_extmarks(start_line, end_line)
|
||||
for line = start_line, end_line do
|
||||
local indent = self:_get_indent_size(line, ts_has_errors, self._fallback_pattern)
|
||||
|
||||
if indent > 0 then
|
||||
-- NOTE: `ephemeral = true` is not implemented for `inline` virt_text_pos :(
|
||||
pcall(vim.api.nvim_buf_set_extmark, self._bufnr, self._ns_id, line, 0, {
|
||||
-- HACK: The 'space' character below is not a space, it is actually a "Braille Pattern Blank"
|
||||
-- character, U+2800. This avoids issues with how `indentexpr` is calculated by not using
|
||||
-- spaces (which the `indentexpr` is looking for).
|
||||
virt_text = { { string.rep("⠀", indent), "VirtIndent" } },
|
||||
virt_text_pos = "inline",
|
||||
right_gravity = false,
|
||||
priority = 110,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Enables virtual indentation in registered buffer
|
||||
function VirtualIndent:attach()
|
||||
if self._attached then
|
||||
return
|
||||
end
|
||||
self:set_indent(0, vim.api.nvim_buf_line_count(self._bufnr) - 1, true)
|
||||
|
||||
vim.api.nvim_buf_attach(self._bufnr, false, {
|
||||
on_lines = function(_, _, _, start_line, _, end_line)
|
||||
if not self._attached then
|
||||
return true
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
self:set_indent(start_line, end_line)
|
||||
end)
|
||||
end,
|
||||
on_reload = function()
|
||||
self:set_indent(0, vim.api.nvim_buf_line_count(self._bufnr) - 1, true)
|
||||
end,
|
||||
on_detach = function(_, bufnr)
|
||||
self:detach()
|
||||
self._bufnrs[bufnr] = nil
|
||||
end,
|
||||
})
|
||||
self._attached = true
|
||||
end
|
||||
|
||||
function VirtualIndent:detach()
|
||||
if not self._attached then
|
||||
return
|
||||
end
|
||||
self:_delete_old_extmarks(0, vim.api.nvim_buf_line_count(self._bufnr) - 1)
|
||||
self._attached = false
|
||||
end
|
||||
|
||||
return VirtualIndent
|
23
lua/vindent/init.lua
Normal file
23
lua/vindent/init.lua
Normal file
@ -0,0 +1,23 @@
|
||||
local M = {}
|
||||
|
||||
function M.ftplugin_setup()
|
||||
if vim.b.did_ftplugin then
|
||||
return
|
||||
end
|
||||
vim.b.did_ftplugin = true
|
||||
|
||||
vim.b.vindent_enabled = true
|
||||
local vindent = require("vindent.indent"):new()
|
||||
if vindent then
|
||||
vindent:attach()
|
||||
end
|
||||
end
|
||||
|
||||
---Gets the current VirtualIndent instance
|
||||
---@param buf? number Buffer number or 0 for current buffer
|
||||
---@return VirtualIndent? The VirtualIndent instance if it exists
|
||||
function M.get_buf_vindent(buf)
|
||||
return require("vindent.indent"):new(buf)
|
||||
end
|
||||
|
||||
return M
|
53
lua/vindent/treesitter/markdown.lua
Normal file
53
lua/vindent/treesitter/markdown.lua
Normal file
@ -0,0 +1,53 @@
|
||||
local M = {}
|
||||
|
||||
function M.find_headline(node)
|
||||
if node:type() == "atx_heading" then
|
||||
return node
|
||||
end
|
||||
|
||||
if node:type() == "section" then
|
||||
-- The headline is always the first child of a section
|
||||
local child = node:child "atx_heading"
|
||||
if child then
|
||||
return child
|
||||
end
|
||||
end
|
||||
|
||||
if node:parent() then
|
||||
return M.find_headline(node:parent())
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function M.headline_level(headline)
|
||||
local heading_content = headline:field "heading_content"
|
||||
if not heading_content or #heading_content == 0 then
|
||||
return 0
|
||||
end
|
||||
local _, level = heading_content[1]:start()
|
||||
return level - 1
|
||||
end
|
||||
|
||||
function M.get_node_at_cursor(cursor)
|
||||
if not cursor then
|
||||
return vim.treesitter.get_node()
|
||||
end
|
||||
|
||||
return vim.treesitter.get_node {
|
||||
bufnr = 0,
|
||||
pos = { cursor[1] - 1, cursor[2] },
|
||||
}
|
||||
end
|
||||
|
||||
function M.closest_headline_node(cursor)
|
||||
local node = M.get_node_at_cursor(cursor)
|
||||
|
||||
if not node then
|
||||
return nil
|
||||
end
|
||||
|
||||
return M.find_headline(node)
|
||||
end
|
||||
|
||||
return M
|
46
lua/vindent/treesitter/org.lua
Normal file
46
lua/vindent/treesitter/org.lua
Normal file
@ -0,0 +1,46 @@
|
||||
local M = {}
|
||||
|
||||
function M.find_headline(node)
|
||||
if node:type() == "headline" then
|
||||
return node
|
||||
end
|
||||
|
||||
if node:type() == "section" then
|
||||
-- The headline is always the first child of a section
|
||||
return node:child("headline")[1]
|
||||
end
|
||||
|
||||
if node:parent() then
|
||||
return M.find_headline(node:parent())
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function M.get_node_at_cursor(cursor)
|
||||
if not cursor then
|
||||
return vim.treesitter.get_node()
|
||||
end
|
||||
|
||||
return vim.treesitter.get_node {
|
||||
bufnr = 0,
|
||||
pos = { cursor[1] - 1, cursor[2] },
|
||||
}
|
||||
end
|
||||
|
||||
function M.headline_level(headline)
|
||||
local _, level = headline:field("stars")[1]:end_()
|
||||
return level + 1
|
||||
end
|
||||
|
||||
function M.closest_headline_node(cursor)
|
||||
local node = M.get_node_at_cursor(cursor)
|
||||
|
||||
if not node then
|
||||
return nil
|
||||
end
|
||||
|
||||
return M.find_headline(node)
|
||||
end
|
||||
|
||||
return M
|
4
stylua.toml
Normal file
4
stylua.toml
Normal file
@ -0,0 +1,4 @@
|
||||
line_endings = "Unix"
|
||||
indent_type = "Spaces"
|
||||
indent_width = 2
|
||||
no_call_parentheses = true
|
92
tests/minimal_init.lua
Normal file
92
tests/minimal_init.lua
Normal file
@ -0,0 +1,92 @@
|
||||
local M = {}
|
||||
|
||||
---@class OrgMinPlugin A plugin to download and register on the package path
|
||||
---@alias OrgPluginName string The plugin name, will be used as part of the git clone destination
|
||||
---@alias OrgPluginUrl string The git url at which a plugin is located, can be a path. See https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols for details
|
||||
---@alias OrgMinPlugins table<OrgPluginName, OrgPluginUrl>
|
||||
|
||||
-- Gets the current directory of this file
|
||||
local base_root_path = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h")
|
||||
---Gets the root directory of the minimal init and if path is specified appends the given path to the root allowing for
|
||||
---subdirectories within the current cwd
|
||||
---@param path string? The additional path to append to the root, not required
|
||||
---@return string root The root path suffixed with the path provided or an empty suffix if none was given
|
||||
function M.root(path)
|
||||
return base_root_path .. "/.deps/" .. (path or "")
|
||||
end
|
||||
|
||||
---Downloads a plugin from a given url and registers it on the 'runtimepath'
|
||||
---@param plugin_name OrgPluginName
|
||||
---@param plugin_url OrgPluginUrl
|
||||
function M.load_plugin(plugin_name, plugin_url)
|
||||
local package_root = M.root "plugins/"
|
||||
local install_destination = package_root .. plugin_name
|
||||
vim.opt.runtimepath:append(install_destination)
|
||||
|
||||
if not vim.loop.fs_stat(package_root) then
|
||||
vim.fn.mkdir(package_root, "p")
|
||||
end
|
||||
|
||||
-- If the plugin install path already exists, we don't need to clone it again.
|
||||
if not vim.loop.fs_stat(install_destination) then
|
||||
print(string.format('>> Downloading plugin "%s" to "%s"', plugin_name, install_destination))
|
||||
vim.fn.system {
|
||||
"git",
|
||||
"clone",
|
||||
"--depth=1",
|
||||
plugin_url,
|
||||
install_destination,
|
||||
}
|
||||
if vim.v.shell_error > 0 then
|
||||
error(
|
||||
string.format('>> Failed to clone plugin: "%s" to "%s"!', plugin_name, install_destination),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Do the initial setup. Downloads plugins, ensures the minimal init does not pollute the filesystem by keeping
|
||||
---everything self contained to the CWD of the minimal init file. Run prior to running tests, reproducing issues, etc.
|
||||
---@param plugins? OrgMinPlugins
|
||||
function M.setup(plugins)
|
||||
vim.opt.packpath = {} -- Empty the package path so we use only the plugins specified
|
||||
vim.opt.runtimepath:append(M.root ".min") -- Ensure the runtime detects the root min dir
|
||||
|
||||
-- Install required plugins
|
||||
if plugins ~= nil then
|
||||
for plugin_name, plugin_url in pairs(plugins) do
|
||||
M.load_plugin(plugin_name, plugin_url)
|
||||
end
|
||||
end
|
||||
|
||||
vim.env.XDG_CONFIG_HOME = M.root "xdg/config"
|
||||
vim.env.XDG_DATA_HOME = M.root "xdg/data"
|
||||
vim.env.XDG_STATE_HOME = M.root "xdg/state"
|
||||
vim.env.XDG_CACHE_HOME = M.root "xdg/cache"
|
||||
|
||||
local std_paths = {
|
||||
"cache",
|
||||
"data",
|
||||
"config",
|
||||
}
|
||||
|
||||
for _, std_path in pairs(std_paths) do
|
||||
vim.fn.mkdir(vim.fn.stdpath(std_path), "p")
|
||||
end
|
||||
|
||||
-- NOTE: Cleanup the xdg cache on exit so new runs of the minimal init doesn't share any previous state, e.g. shada
|
||||
vim.api.nvim_create_autocmd("VimLeave", {
|
||||
callback = function()
|
||||
vim.fn.delete(M.root "xdg", "rf")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
M.setup {
|
||||
plenary = "https://github.com/nvim-lua/plenary.nvim.git",
|
||||
treesitter = "https://github.com/nvim-treesitter/nvim-treesitter",
|
||||
}
|
||||
|
||||
-- WARN: Do all plugin setup, test runs, reproductions, etc. AFTER calling setup with a list of plugins!
|
||||
-- Basically, do all that stuff AFTER this line.
|
14
tests/test.lua
Normal file
14
tests/test.lua
Normal file
@ -0,0 +1,14 @@
|
||||
require "tests.minimal_init"
|
||||
---@type string
|
||||
local test_file = vim.v.argv[#vim.v.argv]
|
||||
if test_file == "" or not test_file:find("tests/plenary/", nil, true) then
|
||||
test_file = "tests/tests"
|
||||
print("Running all tests at " .. test_file)
|
||||
else
|
||||
print("Individual Test File/Directory provided: " .. test_file)
|
||||
end
|
||||
|
||||
require("plenary.test_harness").test_directory(test_file, {
|
||||
minimal_init = "tests/minimal_init.lua",
|
||||
sequential = true,
|
||||
})
|
Loading…
Reference in New Issue
Block a user