diff --git a/README.md b/README.md index 93a62e79..e4c2a595 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,11 @@ that are no longer needed, run the `:Rocks prune [rock]` command. The `:Rocks edit` command opens the `rocks.toml` file for manual editing. Make sure to run `:Rocks sync` when you are done. +## :waning_crescent_moon: Lua API + +This plugin provides a Lua API for extensibility. +See [`:h rocks.api`](./doc/rocks.txt) for details. + ## :book: License `rocks.nvim` is licensed under [GPLv3](./LICENSE). diff --git a/doc/rocks.txt b/doc/rocks.txt index 19c9c939..2a322a9c 100644 --- a/doc/rocks.txt +++ b/doc/rocks.txt @@ -4,6 +4,7 @@ Table of Contents *rocks.contents* rocks.nvim ····························································· |rocks| rocks.nvim commands ··········································· |rocks.commands| rocks.nvim configuration ········································ |rocks.config| +rocks.nvim Lua API ················································· |rocks.api| ============================================================================== rocks.nvim *rocks* @@ -50,4 +51,96 @@ RocksOpts *RocksOpts* {lazy?} (boolean) Whether to query luarocks.org lazily. Defaults to `false`. Setting this to `true` may improve startup time, but features like auto-completion will lag initially. +============================================================================== +rocks.nvim Lua API *rocks.api* + + +The Lua API for rocks.nvim. +Intended for use by modules that extend this plugin. + + +Rock *Rock* + + Fields: ~ + {name} (string) + {version} (string) + + +rock_name *rock_name* + + Type: ~ + string + + +api.try_get_cached_rocks() *api.try_get_cached_rocks* + Tries to get the cached rocks. + Returns an empty list if the cache has not been populated + or no connection to luarocks.org can be established. + Will spawn an async task to attempt to populate the cache + if it is not ready. + + Returns: ~ + (table) rocks + + +api.query_luarocks_rocks({callback}) *api.query_luarocks_rocks* + Queries luarocks.org for rocks and passes the rocks + to a callback. Invokes the callback with an empty table + if no rocks are found or no connection to luarocks.org can be established. + + Parameters: ~ + {callback} (fun(rocks:table)) @async + + +FuzzyFilterOpts *FuzzyFilterOpts* + + Fields: ~ + {sort?} (boolean) Whether to sort the results (default: `true`). + + + *api.fuzzy_filter_rock_tbl* +api.fuzzy_filter_rock_tbl({rock_tbl}, {query}, {opts?}) + @generic T + + Parameters: ~ + {rock_tbl} (table) + {query} (string) + {opts?} (FuzzyFilterOpts) + + Returns: ~ + (table) + + +api.query_installed_rocks({callback}) *api.query_installed_rocks* + Query for installed rocks. + Passes the installed rocks (table indexed by name) to a callback when done. + + Parameters: ~ + {callback} (fun(rocks:table)) @async + + +api.get_rocks_toml() *api.get_rocks_toml* + Gets the rocks.toml file path. + Note that the file may not have been created yet. + + Returns: ~ + (string) rocks_toml_file + + +RocksCmd *RocksCmd* + + Fields: ~ + {impl} (fun(args:string[])) The command implementation + {complete?} (fun(subcmd_arg_lead:string):string[]) Command completions callback, taking the lead of the subcommand's arguments + + + *api.register_rocks_subcommand* +api.register_rocks_subcommand({name}, {cmd}) + Register a `:Rocks` subcommand. + + Parameters: ~ + {name} (string) The name of the subcommand to register + {cmd} (RocksCmd) + + vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/rocks/api.lua b/lua/rocks/api.lua new file mode 100644 index 00000000..a9ad641d --- /dev/null +++ b/lua/rocks/api.lua @@ -0,0 +1,110 @@ +---@mod rocks.api rocks.nvim Lua API +--- +---@brief [[ +--- +---The Lua API for rocks.nvim. +---Intended for use by modules that extend this plugin. +--- +---@brief ]] + +-- Copyright (C) 2023 Neorocks Org. +-- +-- Version: 0.1.0 +-- License: GPLv3 +-- Created: 07 Dec 2023 +-- Updated: 07 Dec 2023 +-- Homepage: https://github.com/nvim-neorocks/rocks.nvim +-- Maintainer: NTBBloodbath + +---@class Rock +---@field name string +---@field version string + +---@alias rock_name string + +local api = {} + +local nio = require("nio") +local cache = require("rocks.cache") +local luarocks = require("rocks.luarocks") +local fzy = require("rocks.fzy") +local state = require("rocks.state") +local commands = require("rocks.commands") +local config = require("rocks.config.internal") + +---Tries to get the cached rocks. +---Returns an empty list if the cache has not been populated +---or no connection to luarocks.org can be established. +---Will spawn an async task to attempt to populate the cache +---if it is not ready. +---@return table rocks +function api.try_get_cached_rocks() + return cache.try_get_rocks() +end + +---Queries luarocks.org for rocks and passes the rocks +---to a callback. Invokes the callback with an empty table +---if no rocks are found or no connection to luarocks.org can be established. +---@param callback fun(rocks: table) +---@async +function api.query_luarocks_rocks(callback) + nio.run(luarocks.search_all, function(success, rocks) + if success then + callback(rocks) + end + end) +end + +---@class FuzzyFilterOpts +---@field sort? boolean Whether to sort the results (default: `true`). + +---@generic T +---@param rock_tbl table +---@param query string +---@param opts? FuzzyFilterOpts +---@return table +function api.fuzzy_filter_rock_tbl(rock_tbl, query, opts) + vim.validate({ query = { query, "string" } }) + if opts then + vim.validate({ sort = { opts.sort, "boolean", true } }) + end + local matching_names = fzy.fuzzy_filter(query, vim.tbl_keys(rock_tbl), opts) + local result = vim.empty_dict() + ---@cast result table + for _, match in pairs(matching_names) do + result[match] = rock_tbl[match] + end + return result +end + +---Query for installed rocks. +---Passes the installed rocks (table indexed by name) to a callback when done. +---@param callback fun(rocks: table) +---@async +function api.query_installed_rocks(callback) + nio.run(state.installed_rocks, function(success, rocks) + if success then + callback(rocks) + end + end) +end + +---Gets the rocks.toml file path. +---Note that the file may not have been created yet. +---@return string rocks_toml_file +function api.get_rocks_toml() + return config.config_path +end + +---@class RocksCmd +---@field impl fun(args:string[]) The command implementation +---@field complete? fun(subcmd_arg_lead: string): string[] Command completions callback, taking the lead of the subcommand's arguments + +---Register a `:Rocks` subcommand. +---@param name string The name of the subcommand to register +---@param cmd RocksCmd +function api.register_rocks_subcommand(name, cmd) + commands.register_subcommand(name, cmd) +end + +return api diff --git a/lua/rocks/cache.lua b/lua/rocks/cache.lua index e9d7b9b0..789370a7 100644 --- a/lua/rocks/cache.lua +++ b/lua/rocks/cache.lua @@ -24,37 +24,16 @@ local nio = require("nio") local _cached_rocks = nil ---Query luarocks packages and populate the cache. ----@async +---@type async fun() cache.populate_cached_rocks = nio.create(function() if _cached_rocks then return end - _cached_rocks = vim.empty_dict() - ---@cast _cached_rocks Rock[] - local future = nio.control.future() - luarocks.cli({ "search", "--porcelain", "--all" }, function(obj) - ---@cast obj vim.SystemCompleted - future.set(obj) - end, { text = true }) - ---@type vim.SystemCompleted - local obj = future.wait() - local result = obj.stdout - if obj.code ~= 0 or not result then - -- set cache back to nil so that we can retry again - _cached_rocks = nil - return - end - for name, version in result:gmatch("(%S+)%s+(%S+)%srockspec%s+[^\n]+") do - if name ~= "lua" then - local rock_list = _cached_rocks[name] or vim.empty_dict() - ---@cast rock_list Rock[] - table.insert(rock_list, { name = name, version = version }) - _cached_rocks[name] = rock_list + luarocks.search_all(function(rocks) + if not vim.tbl_isempty(rocks) then + _cached_rocks = rocks end - end - if vim.tbl_isempty(_cached_rocks) then - _cached_rocks = nil - end + end) end) ---Tries to get the cached rocks. diff --git a/lua/rocks/commands.lua b/lua/rocks/commands.lua index 8212a037..434cc06a 100644 --- a/lua/rocks/commands.lua +++ b/lua/rocks/commands.lua @@ -63,36 +63,66 @@ local function complete_names(query) return {} end local rock_names = vim.tbl_keys(rocks) - return fzy.fuzzy_filter_sort(query, rock_names) + return fzy.fuzzy_filter(query, rock_names) end ----@type { [string]: fun(args:string[]) } +---@type { [string]: RocksCmd } local rocks_command_tbl = { - update = function(_) - require("rocks.operations").update() - end, - sync = function(_) - require("rocks.operations").sync() - end, - install = function(args) - if #args == 0 then - vim.notify("Rocks install: Called without required package argument.", vim.log.levels.ERROR) - return - end - local package, version = args[1], args[2] - require("rocks.operations").add(package, version) - end, - prune = function(args) - if #args == 0 then - vim.notify("Rocks prune: Called without required package argument.", vim.log.levels.ERROR) - return - end - local package = args[1] - require("rocks.operations").prune(package) - end, - edit = function(_) - vim.cmd.e(require("rocks.config.internal").config_path) - end, + update = { + impl = function(_) + require("rocks.operations").update() + end, + }, + sync = { + impl = function(_) + require("rocks.operations").sync() + end, + }, + install = { + impl = function(args) + if #args == 0 then + vim.notify("Rocks install: Called without required package argument.", vim.log.levels.ERROR) + return + end + local package, version = args[1], args[2] + require("rocks.operations").add(package, version) + end, + completions = function(query) + local name, version_query = query:match("([^%s]+)%s(.+)$") + -- name followed by space, but no version? + name = name or query:match("([^%s]+)%s$") + if version_query or name then + local version_list = complete_versions(name, version_query) + if #version_list > 0 then + return version_list + end + end + local name_query = query:match("(.*)$") + return complete_names(name_query) + end, + }, + prune = { + impl = function(args) + if #args == 0 then + vim.notify("Rocks prune: Called without required package argument.", vim.log.levels.ERROR) + return + end + local package = args[1] + require("rocks.operations").prune(package) + end, + completions = function(query) + local state = require("rocks.state") + local rocks_list = state.complete_removable_rocks(query) + if #rocks_list > 0 then + return rocks_list + end + end, + }, + edit = { + impl = function(_) + vim.cmd.e(require("rocks.config.internal").config_path) + end, + }, } local function rocks(opts) @@ -104,7 +134,7 @@ local function rocks(opts) vim.notify("Rocks: Unknown command: " .. cmd, vim.log.levels.ERROR) return end - command(args) + command.impl(args) end ---@package @@ -114,32 +144,24 @@ function commands.create_commands() desc = "Interacts with currently installed rocks", complete = function(arg_lead, cmdline, _) local rocks_commands = vim.tbl_keys(rocks_command_tbl) - - local name, version_query = cmdline:match("^Rocks install%s([^%s]+)%s(.+)$") - -- name followed by space, but no version? - name = name or cmdline:match("^Rocks install%s([^%s]+)%s$") - if version_query or name then - local version_list = complete_versions(name, version_query) - if #version_list > 0 then - return version_list - end - end - local name_query = cmdline:match("^Rocks install%s(.*)$") - local rocks_list = complete_names(name_query) - if #rocks_list > 0 then - return rocks_list - end - local state = require("rocks.state") - name_query = cmdline:match("^Rocks prune%s(.*)$") - rocks_list = state.complete_removable_rocks(name_query) - if #rocks_list > 0 then - return rocks_list + local subcmd, subcmd_arg_lead = cmdline:match("^Rocks%s(%S+)%s(.*)$") + if subcmd and subcmd_arg_lead and rocks_command_tbl[subcmd] and rocks_command_tbl[subcmd].complete then + return rocks_command_tbl[subcmd].complete(subcmd_arg_lead) end if cmdline:match("^Rocks%s+%w*$") then - return fzy.fuzzy_filter_sort(arg_lead, rocks_commands) + return fzy.fuzzy_filter(arg_lead, rocks_commands) end end, }) end +---@param name string The name of the subcommand +---@param cmd RocksCmd The implementation and optional completions +---@package +function commands.register_subcommand(name, cmd) + vim.validate({ name = { name, "string" } }) + vim.validate({ impl = { cmd.impl, "function" }, completions = { cmd.complete, "function", true } }) + rocks_command_tbl[name] = cmd +end + return commands diff --git a/lua/rocks/config/internal.lua b/lua/rocks/config/internal.lua index 83871d82..5e809476 100644 --- a/lua/rocks/config/internal.lua +++ b/lua/rocks/config/internal.lua @@ -44,16 +44,20 @@ local default_config = { ---@type RocksOpts local opts = type(vim.g.rocks_nvim) == "function" and vim.g.rocks_nvim() or vim.g.rocks_nvim or {} -local config = vim.tbl_deep_extend("force", {}, default_config, opts) - local check = require("rocks.config.check") + +---@type RocksConfig +local config = vim.tbl_deep_extend("force", { + debug_info = { + urecognized_configs = check.get_unrecognized_keys(opts, default_config), + }, +}, default_config, opts) + local ok, err = check.validate(config) if not ok then vim.notify("Rocks: " .. err, vim.log.levels.ERROR) end -config.debug_info.urecognized_configs = check.get_unrecognized_keys(opts, default_config) - if #config.debug_info.unrecognized_configs > 0 then vim.notify( "unrecognized configs found in vim.g.rocks_nvim: " .. vim.inspect(config.debug_info.unrecognized_configs), diff --git a/lua/rocks/fzy.lua b/lua/rocks/fzy.lua index 29e327fb..20ce8611 100644 --- a/lua/rocks/fzy.lua +++ b/lua/rocks/fzy.lua @@ -29,16 +29,20 @@ local fzy = require("fzy") ---Fuzzy-filter a list of items. ---@param query string The query to fuzzy-match. ---@param items string[] The items to search. ----@return string[] Matching items, sorted by score (higher score first). -function fzy_adapter.fuzzy_filter_sort(query, items) +---@param opts? FuzzyFilterOpts Filtering options. +---@return string[] matching_items +function fzy_adapter.fuzzy_filter(query, items, opts) + opts = opts or { sort = true } local fzy_results = fzy.filter(query, items) or {} - table.sort(fzy_results, function(a, b) - ---@cast a FzyResult - ---@cast b FzyResult - local score_a = a[3] - local score_b = b[3] - return score_a > score_b - end) + if opts.sort then + table.sort(fzy_results, function(a, b) + ---@cast a FzyResult + ---@cast b FzyResult + local score_a = a[3] + local score_b = b[3] + return score_a > score_b + end) + end return vim.iter(fzy_results) :map(function(fzy_result) ---@cast fzy_result FzyResult diff --git a/lua/rocks/internal-types.lua b/lua/rocks/internal-types.lua deleted file mode 100644 index dbeb7464..00000000 --- a/lua/rocks/internal-types.lua +++ /dev/null @@ -1,29 +0,0 @@ ----@mod rocks.internal-types --- --- Copyright (C) 2023 Neorocks Org. --- --- Version: 0.1.0 --- License: GPLv3 --- Created: 05 Jul 2023 --- Updated: 27 Aug 2023 --- Homepage: https://github.com/nvim-neorocks/rocks.nvim --- Maintainers: NTBBloodbath , Vhyrro --- ----@brief [[ --- --- rocks.nvim internal type definitions. --- ----@brief ]] - ----@class (exact) Rock ----@field public name string ----@field public version string - ----@class OutdatedRock: Rock ----@field public target_version string - ----@class (exact) RockDependency ----@field public name string ----@field public version? string - ---- internal-types.lua ends here diff --git a/lua/rocks/luarocks.lua b/lua/rocks/luarocks.lua index 714e0bfa..6b855133 100644 --- a/lua/rocks/luarocks.lua +++ b/lua/rocks/luarocks.lua @@ -19,6 +19,7 @@ local luarocks = {} local constants = require("rocks.constants") local config = require("rocks.config.internal") +local nio = require("nio") ---@param args string[] luarocks CLI arguments ---@param on_exit (function|nil) Called asynchronously when the luarocks command exits. @@ -36,6 +37,34 @@ luarocks.cli = function(args, on_exit, opts) return vim.system(luarocks_cmd, opts, on_exit and vim.schedule_wrap(on_exit)) end +---Search luarocks.org for all packages. +---@type async fun(callback: fun(rocks_table: { [string]: Rock } )) +luarocks.search_all = nio.create(function(callback) + local rocks_table = vim.empty_dict() + ---@cast rocks_table { [string]: Rock } + local future = nio.control.future() + luarocks.cli({ "search", "--porcelain", "--all" }, function(obj) + ---@cast obj vim.SystemCompleted + future.set(obj) + end, { text = true }) + ---@type vim.SystemCompleted + local obj = future.wait() + local result = obj.stdout + if obj.code ~= 0 or not result then + callback(vim.empty_dict()) + return + end + for name, version in result:gmatch("(%S+)%s+(%S+)%srockspec%s+[^\n]+") do + if name ~= "lua" then + local rock_list = rocks_table[name] or vim.empty_dict() + ---@cast rock_list Rock[] + table.insert(rock_list, { name = name, version = version }) + rocks_table[name] = rock_list + end + end + callback(rocks_table) +end) + return luarocks -- end of luarocks.lua diff --git a/lua/rocks/operations.lua b/lua/rocks/operations.lua index b2136e80..c6d8b113 100644 --- a/lua/rocks/operations.lua +++ b/lua/rocks/operations.lua @@ -91,7 +91,7 @@ end ---Removes a rock, and recursively removes its dependencies ---if they are no longer needed. ----@type fun(name: string) +---@type async fun(name: string) operations.remove_recursive = nio.create(function(name) ---@cast name string local dependencies = state.rock_dependencies(name) diff --git a/lua/rocks/state.lua b/lua/rocks/state.lua index 24d1ea98..64eba1e9 100644 --- a/lua/rocks/state.lua +++ b/lua/rocks/state.lua @@ -25,8 +25,7 @@ local luarocks = require("rocks.luarocks") local fzy = require("rocks.fzy") local nio = require("nio") ----@async ----@type fun(): {[string]: Rock} +---@type async fun(): {[string]: Rock} state.installed_rocks = nio.create(function() local rocks = vim.empty_dict() ---@cast rocks {[string]: Rock} @@ -53,8 +52,7 @@ state.installed_rocks = nio.create(function() return rocks end) ----@async ----@type fun(): {[string]: OutdatedRock} +---@type async fun(): {[string]: OutdatedRock} state.outdated_rocks = nio.create(function() local rocks = vim.empty_dict() ---@cast rocks {[string]: Rock} @@ -83,8 +81,7 @@ state.outdated_rocks = nio.create(function() end) ---List the dependencies of an installed Rock ----@async ----@type fun(rock:Rock|string): {[string]: RockDependency} +---@type async fun(rock:Rock|string): {[string]: RockDependency} state.rock_dependencies = nio.create(function(rock) ---@cast rock Rock|string @@ -126,8 +123,7 @@ end) ---List installed rocks that are not dependencies of any other rocks ---and can be removed. ----@async ----@type fun(): string[] +---@type async fun(): string[] state.query_removable_rocks = nio.create(function() local installed_rocks = state.installed_rocks() --- Unfortunately, luarocks can't list dependencies via its CLI. @@ -146,7 +142,7 @@ state.query_removable_rocks = nio.create(function() :totable() end) ----@async +---@type async fun() local populate_removable_rock_cache = nio.create(function() if _removable_rock_cache then return @@ -166,7 +162,7 @@ state.complete_removable_rocks = function(query) if not query then return {} end - return fzy.fuzzy_filter_sort(query, _removable_rock_cache) + return fzy.fuzzy_filter(query, _removable_rock_cache) end state.invalidate_cache = function() diff --git a/lua/rocks/types.lua b/lua/rocks/types.lua index 4ae1f6f7..7e1b5d28 100644 --- a/lua/rocks/types.lua +++ b/lua/rocks/types.lua @@ -11,12 +11,15 @@ -- ---@brief [[ -- --- rocks.nvim type definitions. +-- rocks.nvim internal type definitions. -- ---@brief ]] ----@class (exact) RocksOptions ----@field rocks_path? string Local path in your filesystem to install rocks ----@field config_path? string Rocks declaration file path +---@class OutdatedRock: Rock +---@field public target_version string ---- types.lua ends here +---@class (exact) RockDependency +---@field public name string +---@field public version? string + +--- internal-types.lua ends here diff --git a/nix/plugin-overlay.nix b/nix/plugin-overlay.nix index 9b2a2556..94feb6a2 100644 --- a/nix/plugin-overlay.nix +++ b/nix/plugin-overlay.nix @@ -31,8 +31,17 @@ packageOverrides = rocks-nvim-luaPackage-override; }; lua51Packages = final.lua5_1.pkgs; + luajit = prev.luajit.override { + packageOverrides = rocks-nvim-luaPackage-override; + }; + luajitPackages = final.luajit.pkgs; in { - inherit lua5_1 lua51Packages; + inherit + lua5_1 + lua51Packages + luajit + luajitPackages + ; vimPlugins = prev.vimPlugins diff --git a/nix/test-overlay.nix b/nix/test-overlay.nix index 4d2e52a7..214257a7 100644 --- a/nix/test-overlay.nix +++ b/nix/test-overlay.nix @@ -34,7 +34,7 @@ ]; text = '' mkdir -p doc - lemmy-help lua/rocks/{init,commands,config/init}.lua > doc/rocks.txt + lemmy-help lua/rocks/{init,commands,config/init,api}.lua > doc/rocks.txt ''; }; in { diff --git a/spec/api_spec.lua b/spec/api_spec.lua new file mode 100644 index 00000000..0a155c09 --- /dev/null +++ b/spec/api_spec.lua @@ -0,0 +1,23 @@ +local api = require("rocks.api") +local spy = require("luassert.spy") +describe("Lua API", function() + it("fuzzy_filter_rocks_table", function() + local result = api.fuzzy_filter_rock_tbl({ + neorg = { name = "neorg", version = "1.0.0" }, + foo = { name = "foo", version = "1.0.0" }, + }, "nrg") + assert.same({ neorg = { name = "neorg", version = "1.0.0" } }, result) + end) + it("register_rocks_subcommand", function() + require("rocks.commands").create_commands() + local s = spy.new(function() end) + local cmd = { + impl = function(args) + s(args) + end, + } + api.register_rocks_subcommand("test", cmd) + vim.cmd.Rocks({ "test", "foo" }) + assert.spy(s).called_with({ "foo" }) + end) +end)