diff --git a/README.md b/README.md index 8153315..c63669a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ AstroCore provides the core Lua API that powers [AstroNvim](https://github.com/A ## ⚡️ Requirements -- Neovim >= 0.10 +- Neovim >= 0.11 - [lazy.nvim](https://github.com/folke/lazy.nvim) - [resession.nvim][resession] (_optional_) @@ -197,6 +197,54 @@ local opts = { buftypes = {}, -- buffer types to ignore sessions }, }, + -- Configuration of treesitter features in Neovim + treesitter = { + -- Globally enable or disable treesitter features + -- can be a boolean or a function (`fun(lang: string, bufnr: integer): boolean`) + enabled = true, + -- Enable or disable treesitter based highlighting + -- can be a boolean, list of parsers, or a function (`fun(lang: string, bufnr: integer): boolean`) + highlight = true, + -- Enable or disable treesitter based indenting + -- can be a boolean, list of parsers, or a function (`fun(lang: string, bufnr: integer): boolean`) + indent = true, + -- List of treesitter parsers that should be installed automatically + -- ("all" can be used to install all available parsers) + ensure_installed = { "lua", "vim", "vimdoc" }, + -- Automatically detect missing treesitter parser and install when editing file + auto_install = false, + -- Configure treesitter based text objects + textobjects = { + select = { + select_textobject = { + ["af"] = { query = "@function.outer", desc = "around function" }, + ["if"] = { query = "@function.inner", desc = "around function" }, + }, + }, + move = { + goto_next_start = { + ["]f"] = { query = "@function.outer", desc = "Next function start" }, + }, + goto_next_end = { + ["]F"] = { query = "@function.outer", desc = "Next function end" }, + }, + goto_previous_start = { + ["[f"] = { query = "@function.outer", desc = "Previous function start" }, + }, + goto_previous_end = { + ["[F"] = { query = "@function.outer", desc = "Previous function end" }, + }, + }, + swap = { + swap_next = { + [">F"] = { query = "@function.outer", desc = "Swap next function" }, + }, + swap_previous = { + [" + +---@class AstroCoreTreesitterTextObjectsSelectOpts +---@field select_textobject AstroCoreTreesitterTextObjectsKeys? Keymaps for selecting a given treesitter capture group + +---@class AstroCoreTreesitterTextObjectsMoveOpts +---@field goto_next_start AstroCoreTreesitterTextObjectsKeys? Keymaps for going to the start of the next treesitter capture group +---@field goto_next_end AstroCoreTreesitterTextObjectsKeys? Keymaps for going to the end of the next treesitter capture group +---@field goto_previous_start AstroCoreTreesitterTextObjectsKeys? Keymaps for going to the start of the previous treesitter capture group +---@field goto_previous_end AstroCoreTreesitterTextObjectsKeys? Keymaps for going to the end of the previous treesitter capture group + +---@class AstroCoreTreesitterTextObjectsSwapOpts +---@field swap_next AstroCoreTreesitterTextObjectsKeys? Keymaps for swapping with the next treesitter capture group +---@field swap_previous AstroCoreTreesitterTextObjectsKeys? Keymaps for swapping with the previous treesitter capture group + +---@class AstroCoreTreesitterTextObjects +---@field select AstroCoreTreesitterTextObjectsSelectOpts? Keymaps for selection of treesitter capture groups +---@field move AstroCoreTreesitterTextObjectsMoveOpts? Keymaps for moving treesitter capture groups +---@field swap AstroCoreTreesitterTextObjectsSwapOpts? Keymaps for swapping treesitter capture groups + +---@class AstroCoreTreesitterOpts +---@field enabled AstroCoreTreesitterEnable? Control over the global enabling of treesitter features +---Whether or not to enable treesitter based highlighting. Can be one of the following: +--- +--- - A boolean to apply to all languages +--- - A list of languages to enable +--- - A function that takes a language and a buffer number and returns a boolean +---Examples: +--- +---```lua +---highlight = true -- enables for all languages +---highlight = { "c", "rust" } -- only enables for some languages +---highlight = function(lang, bufnr) -- use a function to decide, for example setting a max filesize +--- local max_filesize = 100 * 1024 -- 100KB +--- local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(bufnr)) +--- if ok and stats and stats.size > max_filesize then return true +---end +---``` +---@field highlight AstroCoreTreesitterFeature? +---Whether or not to enable treesitter based indentation. Can be one of the following: +--- +--- - A boolean to apply to all languages +--- - A list of languages to enable +--- - A function that takes a language and a buffer number and returns a boolean +---Examples: +--- +---```lua +---indent = true -- enables for all languages +---indent = { "c", "rust" } -- only enables for some languages +---indent = function(lang, bufnr) -- use a function to decide, for example setting a max filesize +--- local max_filesize = 100 * 1024 -- 100KB +--- local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(bufnr)) +--- if ok and stats and stats.size > max_filesize then return true +---end +---``` +---@field indent AstroCoreTreesitterFeature? +---@field auto_install boolean? whether or not to automatically detect and install missing treesitter parsers +---@field ensure_installed string[]|"all"? a list of treesitter parsers to ensure are installed, "all" will install all parsers, "auto" will install when opening a filetype with an available parser +---Configuration of textobject mappings to create using `nvim-treesitter-textobjects` +--- +---Examples: +--- +---```lua +---textobjects = { +--- select = { +--- select_textobject = { +--- ["af"] = { query = "@function.outer", desc = "around function" }, +--- ["if"] = { query = "@function.inner", desc = "around function" }, +--- }, +--- }, +--- move = { +--- goto_next_start = { +--- ["]f"] = { query = "@function.outer", desc = "Next function start" }, +--- }, +--- goto_next_end = { +--- ["]F"] = { query = "@function.outer", desc = "Next function end" }, +--- }, +--- goto_previous_start = { +--- ["[f"] = { query = "@function.outer", desc = "Previous function start" }, +--- }, +--- goto_previous_end = { +--- ["[F"] = { query = "@function.outer", desc = "Previous function end" }, +--- }, +--- }, +--- swap = { +--- swap_next = { +--- [">F"] = { query = "@function.outer", desc = "Swap next function" }, +--- }, +--- swap_previous = { +--- [" 0 then vim.api.nvim_create_autocmd("User", { pattern = "MasonToolsUpdateCompleted", @@ -687,6 +692,8 @@ function M.setup(opts) end end + if vim.tbl_get(M.config, "treesitter") then require("astrocore.treesitter").setup(M.config.treesitter) end + local astroui_avail, astroui = pcall(require, "astroui") if astroui_avail then astroui.set_colorscheme() end end diff --git a/lua/astrocore/toggles.lua b/lua/astrocore/toggles.lua index c222d54..4ce9561 100644 --- a/lua/astrocore/toggles.lua +++ b/lua/astrocore/toggles.lua @@ -185,14 +185,14 @@ end function M.buffer_syntax(bufnr, silent) -- HACK: this should just be `bufnr = bufnr or 0` but it looks like `vim.treesitter.stop` has a bug with `0` being current bufnr = (bufnr and bufnr ~= 0) and bufnr or vim.api.nvim_win_get_buf(0) - local ts_avail, parsers = pcall(require, "nvim-treesitter.parsers") + local treesitter = require "astrocore.treesitter" local astrolsp_avail, lsp_toggle = pcall(require, "astrolsp.toggles") if vim.bo[bufnr].syntax == "off" then - if ts_avail and parsers.has_parser() then vim.treesitter.start(bufnr) end + if treesitter.has_parser() then vim.treesitter.start(bufnr) end vim.bo[bufnr].syntax = "on" if astrolsp_avail and not vim.b[bufnr].semantic_tokens then lsp_toggle.buffer_semantic_tokens(bufnr, true) end else - if ts_avail and parsers.has_parser() then vim.treesitter.stop(bufnr) end + if treesitter.has_parser() then vim.treesitter.stop(bufnr) end vim.bo[bufnr].syntax = "off" if astrolsp_avail and vim.b[bufnr].semantic_tokens then lsp_toggle.buffer_semantic_tokens(bufnr, true) end end @@ -245,8 +245,6 @@ local previous_virtual_lines ---@param silent? boolean if true then don't sent a notification function M.virtual_lines(silent) local virtual_lines = vim.diagnostic.config().virtual_lines - -- TODO: remove check when dropping support for Neovim v0.10 - if virtual_lines == nil then ui_notify(silent, "Virtual lines not available") end local new_virtual_lines = false if virtual_lines then previous_virtual_lines = virtual_lines diff --git a/lua/astrocore/treesitter.lua b/lua/astrocore/treesitter.lua new file mode 100644 index 0000000..ff2b48a --- /dev/null +++ b/lua/astrocore/treesitter.lua @@ -0,0 +1,268 @@ +---AstroNvim Treesitter Utilities +--- +---Utilities necessary for configuring treesitter in Neovim +--- +---This module can be loaded with `local astrocore_treesitter = require "astrocore.treesitter"` +--- +---copyright 2025 +---license GNU General Public License v3.0 +---@class astrocore.treesitter +local M = {} + +---@type AstroCoreTreesitterOpts +local config = {} + +local available +local installed = {} +local queries = {} +local captures = {} + +local enabled = {} +local indentexprs = {} + +--- Configure the keymap modes for each textobject type +M.textobject_modes = { + select = { "x", "o" }, + swap = { "n" }, + move = { "n", "x", "o" }, +} + +--- Get list of treesitter parsers installed with `nvim-treesitter` +---@param update boolean? whether or not to refresh installed parsers +---@return string[] # the list of installed parsers +function M.installed(update) + if update then + local treesitter_avail, treesitter = pcall(require, "nvim-treesitter") + if treesitter_avail then + installed, queries = {}, {} + for _, lang in ipairs(treesitter.get_installed "parsers") do + installed[lang] = true + end + end + end + return installed +end + +--- Get available treesitter parers in `nvim-treesitter` +---@return table # a lookup table of available parsers +function M.available() + if available == nil then + available = {} + local treesitter_avail, treesitter = pcall(require, "nvim-treesitter") + if treesitter_avail then + for _, parser in ipairs(treesitter.get_available()) do + available[parser] = true + end + end + end + return available +end + +--- Install the provided parsers with `nvim-treesitter` +---@param languages? "all"|string[] a list of languages to install, automatically detect the current language to install, or install all available parsers (default: "auto") +---@param cb? function optional callback function to execute after installation finishes +function M.install(languages, cb) + local patch_func = require("astrocore").patch_func + local treesitter_avail, treesitter = pcall(require, "nvim-treesitter") + if not treesitter_avail then return end + if not languages then + local lang = vim.treesitter.language.get_lang(vim.bo[vim.api.nvim_get_current_buf()].filetype) + languages = M.available()[lang] and { lang } or {} + elseif languages == "all" then + languages = treesitter.get_available() + end + languages = vim.tbl_filter(function(lang) return not M.has_parser(lang) end, languages --[[ @as string[] ]]) + if + next(languages --[[ @as string[] ]]) + then + cb = patch_func(cb, function(orig) + M.installed(true) + orig() + end) + treesitter.install(languages, { summary = true }):await(cb) + end +end + +--- Check if capture is supported for given treesitter parser language +---@param lang string the parser language to check against +---@param query string the query type to check for support of +---@param capture string the capture type to check for support of +---@return boolean # whether or not a query is supported by the given parser +function M.has_capture(lang, query, capture) + local key = lang .. ":" .. query + if captures[key] == nil then + captures[key] = {} + local found_captures = (vim.treesitter.query.get(lang, query) or {}).captures + for _, found_capture in ipairs(found_captures or {}) do + captures[key][found_capture] = true + end + end + return captures[key][capture] == true +end + +--- Check if query is supported for given treesitter parser language +---@param lang string the parser language to check against +---@param query string the query type to check for support of +---@return boolean # whether or not a query is supported by the given parser +function M.has_query(lang, query) + local key = lang .. ":" .. query + if queries[key] == nil then queries[key] = vim.treesitter.query.get(lang, query) ~= nil end + return queries[key] +end + +--- Check if parser exists for filetype with optional query check +---@param filetype? string|integer the filetype to check or a buffer number to get the filetype of (defaults to current buffer) +---@param query? string the query type to check for support of +---@return boolean # whether or not a parser is supported +function M.has_parser(filetype, query) + if not filetype then filetype = vim.api.nvim_get_current_buf() end + if type(filetype) == "number" then filetype = vim.bo[filetype].filetype end + local lang = vim.treesitter.language.get_lang(filetype --[[ @as string ]]) + if not lang or not M.installed()[lang] then return false end + if query and not M.has_query(lang, query) then return false end + return true +end + +local function _setup() + require("astrocore").on_load("nvim-treesitter", function() + M.installed(true) + M.install(config.ensure_installed) + end) + + vim.api.nvim_create_autocmd("FileType", { + group = vim.api.nvim_create_augroup("astrocore_treesitter", { clear = true }), + desc = "Automatically detect available treesitter parsers and enable necessary features", + callback = function(args) + if enabled[args.buf] == false then return end + local lang = vim.treesitter.language.get_lang(vim.bo[args.buf].filetype) + if not lang then return end + local _enabled = config.enabled + if type(_enabled) == "function" then _enabled = _enabled(lang, args.buf) end + if _enabled then + if not M.has_parser(args.match) then + if config.auto_install then M.install(nil, function() M.enable(args.buf) end) end + else + M.enable(args.buf) + end + else + M.disable(args.buf) + end + end, + }) +end + +--- Initialize treesitter configuration +---@param opts AstroCoreTreesitterOpts +function M.setup(opts) + local astrocore = require "astrocore" + config = astrocore.extend_tbl(config, opts) --[[ @as AstroCoreTreesitterOpts ]] + + if vim.fn.executable "tree-sitter" ~= 1 then + if pcall(require, "mason") and vim.fn.executable "tree-sitter" ~= 1 then + local mr = require "mason-registry" + mr.refresh(function() + local p = mr.get_package "tree-sitter-cli" + if not p:is_installed() then + astrocore.notify "Installing `tree-sitter-cli` with `mason.nvim`..." + p:install( + nil, + vim.schedule_wrap(function(success) + if success then + astrocore.notify "Installed `tree-sitter-cli` with `mason.nvim`." + _setup() + else + astrocore.notify( + "Failed to install `tree-sitter-cli` with `mason.nvim\n\nCheck `:Mason` UI for details.", + vim.log.levels.ERROR + ) + end + end) + ) + end + end) + return + end + if vim.fn.executable "tree-sitter" ~= 1 then + astrocore.notify( + "`tree-sitter` CLI is required for using `nvim-treesitter`\n\nInstall to enable treesitter features.", + vim.log.levels.WARN + ) + return + end + end + _setup() +end + +--- Enable treesitter features in buffer +---@param bufnr? integer the buffer to enable treesitter in +function M.enable(bufnr) + if not bufnr then bufnr = vim.api.nvim_get_current_buf() end + local ft = vim.bo[bufnr].filetype + local lang = vim.treesitter.language.get_lang(ft) + if not M.has_parser(ft) or not lang then return end + enabled[bufnr] = true + + ---@param feat string + ---@param query string + local function feature_enabled(feat, query) + local enable = config[feat] ---@type AstroCoreTreesitterFeature? + if type(enable) == "table" then + enable = vim.tbl_contains(enable, lang) + elseif type(enable) == "function" then + enable = enable(lang, bufnr) + end + return enable and M.has_parser(ft, query) + end + + -- highlighting + if feature_enabled("highlight", "highlights") then pcall(vim.treesitter.start, bufnr) end + + -- indents + if feature_enabled("indent", "indents") then + indentexprs[bufnr] = vim.bo[bufnr].indentexpr + vim.bo[bufnr].indentexpr = "v:lua.require'nvim-treesitter'.indentexpr()" + end + + -- if folds are present force update of folds after loading + if M.has_parser(ft, "folds") then vim.schedule(function() vim.cmd "normal! zx" end) end + + -- treesitter text objects + if config.textobjects and pcall(require, "nvim-treesitter-textobjects") then + for type, methods in pairs(config.textobjects) do + local mode = M.textobject_modes[type] + for method, keys in pairs(methods) do + for key, opts in pairs(keys) do + local group = opts.group or "textobjects" + if M.has_capture(lang, group, string.sub(opts.query, 2)) then + vim.keymap.set( + mode, + key, + function() require("nvim-treesitter-textobjects." .. type)[method](opts.query, group) end, + { buffer = bufnr, desc = opts.desc, silent = true } + ) + end + end + end + end + end +end + +--- Disable treesitter features in buffer +---@param bufnr? integer the buffer to disable treesitter in +function M.disable(bufnr) + if not bufnr then bufnr = vim.api.nvim_get_current_buf() end + enabled[bufnr] = false + pcall(vim.treesitter.stop, bufnr) + if indentexprs[bufnr] then vim.bo[bufnr].indentexpr = indentexprs[bufnr] end + vim.schedule(function() vim.cmd "normal! zx" end) +end + +--- Check if treesitter features in buffer +---@param bufnr? integer the buffer to check if treesitter is enabled for +---@return boolean # whether or not treesitter is enabled in buffer +function M.is_enabled(bufnr) + if not bufnr then bufnr = vim.api.nvim_get_current_buf() end + return enabled[bufnr] == true +end + +return M