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 = { +--- [" # 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