commit e2d177b480ad315b6e43689d0ae8e46d6c39d673 Author: Price Hiller Date: Mon Apr 15 06:31:43 2024 -0500 feat: initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9a6cc75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..415c879 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..49802b7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef7ccc6 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..19946a5 --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/after.png b/assets/after.png new file mode 100644 index 0000000..67a4645 Binary files /dev/null and b/assets/after.png differ diff --git a/assets/before.png b/assets/before.png new file mode 100644 index 0000000..df42b38 Binary files /dev/null and b/assets/before.png differ diff --git a/ftplugin/markdown.lua b/ftplugin/markdown.lua new file mode 100644 index 0000000..b8801b4 --- /dev/null +++ b/ftplugin/markdown.lua @@ -0,0 +1 @@ +require("vindent").ftplugin_setup() diff --git a/ftplugin/org.lua b/ftplugin/org.lua new file mode 100644 index 0000000..b8801b4 --- /dev/null +++ b/ftplugin/org.lua @@ -0,0 +1 @@ +require("vindent").ftplugin_setup() diff --git a/lua/vindent/indent.lua b/lua/vindent/indent.lua new file mode 100644 index 0000000..497cc8b --- /dev/null +++ b/lua/vindent/indent.lua @@ -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 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 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 diff --git a/lua/vindent/init.lua b/lua/vindent/init.lua new file mode 100644 index 0000000..107452b --- /dev/null +++ b/lua/vindent/init.lua @@ -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 diff --git a/lua/vindent/treesitter/markdown.lua b/lua/vindent/treesitter/markdown.lua new file mode 100644 index 0000000..c384cc7 --- /dev/null +++ b/lua/vindent/treesitter/markdown.lua @@ -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 diff --git a/lua/vindent/treesitter/org.lua b/lua/vindent/treesitter/org.lua new file mode 100644 index 0000000..b5c6fa5 --- /dev/null +++ b/lua/vindent/treesitter/org.lua @@ -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 diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..5b6fff8 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,4 @@ +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +no_call_parentheses = true diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..157c55f --- /dev/null +++ b/tests/minimal_init.lua @@ -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 + +-- 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. diff --git a/tests/test.lua b/tests/test.lua new file mode 100644 index 0000000..9a51689 --- /dev/null +++ b/tests/test.lua @@ -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, +})