feat: initial commit

This commit is contained in:
Price Hiller 2024-04-15 06:31:43 -05:00
commit e2d177b480
Signed by: Price
GPG Key ID: C3FADDE7A8534BEB
16 changed files with 521 additions and 0 deletions

3
.editorconfig Normal file
View File

@ -0,0 +1,3 @@
[*.lua]
indent_style = space
indent_size = 2

43
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

1
ftplugin/markdown.lua Normal file
View File

@ -0,0 +1 @@
require("vindent").ftplugin_setup()

1
ftplugin/org.lua Normal file
View File

@ -0,0 +1 @@
require("vindent").ftplugin_setup()

178
lua/vindent/indent.lua Normal file
View 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
View 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

View 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

View 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
View 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
View 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
View 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,
})