diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..eb326ad --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh + +echo "Running tests before commit..." + +# run tests +make test || { + echo "Tests failed. Commit aborted." + exit 1 +} + +echo "Tests passed. Proceeding with commit." diff --git a/.gitignore b/.gitignore index e43b0f9..17402e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +doc/tags diff --git a/LICENSE b/LICENSE index 3cfd7b3..3fcee62 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License -(c) 2021 chiya.dev -(c) 2024 squibid +Copyright (c) 2023-2026 squibid +Copyright (c) 2021-2023 chiya.dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be555db --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +test: + nvim --headless -c "PlenaryBustedDirectory tests/ {minimal_init = './tests/minit.lua'}" + +.PHONY: test diff --git a/README.md b/README.md index 73831e0..48e3938 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,31 @@ # dep -> This readme is a work in progress. -A versatile, declarative and correct [neovim][1] package manager in [Lua][2]. -Originally written for personal use by [luaneko][3]. +A versatile, declarative and correct [neovim][2] package manager in [Lua][3]. +Originally written for personal use by [luaneko][4]. Adapted by [squibid][5] for +general use. What does that mean? -1. `versatile` - packages can be declared in any Lua file in any order of your liking. +1. `versatile` - packages can be declared in any Lua file in any order of your +liking. 2. `declarative` - packages are declared using simple Lua tables. -3. `correct` - packages are always loaded in a correct and consistent order. +3. `correct` - packages are always loaded in a correct and consistent order +(barring any lazy loading). + +In addition to the above dep has been built to be completely in control of you, +the user. With the help of lazy loading you can choose when your plugin loads +down to the finest detail (examples may be found below). + +See also squibid's [neovim-config][10] for an example of how dep can be used in +practice. -See also squibid's [neovim-configs][5] for an example of how dep can be used in practice. ## Requirements -- [Neovim][1] 0.6+ -- [Git][4] + +- [Neovim][2] 0.8+ +- [Git][6] 2.13+ + ## Setup + 1. Create `lua/bootstrap.lua` in your neovim config directory. ```lua @@ -23,7 +34,7 @@ See also squibid's [neovim-configs][5] for an example of how dep can be used in local path = vim.fn.stdpath("data") .. "/site/pack/deps/opt/dep" if vim.fn.empty(vim.fn.glob(path)) > 0 then - vim.fn.system({ "git", "clone", "--depth=1", "https://github.com/chiyadev/dep", path }) + vim.fn.system({ "git", "clone", "--depth=1", "https://git.squi.bid/squibid/dep", path }) end vim.cmd("packadd dep") @@ -44,9 +55,8 @@ require "dep" { cleans removed packages and reloads packages as necessary. - `:DepClean` - cleans removed packages. - `:DepReload` - reloads all packages. -- `:DepList` - prints the package list, performance metrics and dependency graphs. - `:DepLog` - opens the log file. -- `:DepConfig` - opens the file that called dep, for convenience. +- `:DepUi` - opens the ui. ## Package specification @@ -58,28 +68,37 @@ A package must be declared in the following format. -- This is the only required field; all other fields are optional. "user/package", - -- [function] Code to run after the package is loaded into neovim. - function() - require "package".setup(...) - end, - -- [function] Code to run before the package is loaded into neovim. setup = function() vim.g.package_config = ... end, + -- [function] Code to run after the package is loaded into neovim. + load = function() + require "package".setup(...) + end, + -- [function] Code to run after the package is installed or updated. config = function() os.execute(...) end, + -- [function|true] Code used to determine when the package should be loaded. + lazy = function(load) + load:cmd("LoadPackage") + end, + -- [string] Overrides the short name of the package. -- Defaults to a substring of the full name after '/'. as = "custom_package", -- [string] Overrides the URL of the git repository to clone. -- Defaults to "https://github.com/{full_name}.git". - url = "https://git.chiya.dev/user/package.git", + url = "https://git.squi.bid/user/package.git", + + -- [string] Overrides the source in which the package is gotten + -- from. This is not set by default. + path = "~/my-local-package/", -- [string] Overrides the name of the branch to clone. -- Defaults to whatever the remote configured as their HEAD, which is usually "master". @@ -95,9 +114,9 @@ A package must be declared in the following format. -- [boolean] Prevents the package from being updated. pin = true, - -- [string|array] Specifies dependencies that must be loaded before the package. + -- [string|array] Specifies requirements that must be loaded before the package. -- If given a string, it is wrapped into an array. - requires = {...}, + reqs = {...}, -- [string|array] Specifies dependents that must be loaded after the package. -- If given a string, it is wrapped into an array. @@ -123,7 +142,7 @@ combined into one. This is useful when declaring dependencies, which is explored require "dep" { { "user/package", - requires = "user/dependency", + reqs = "user/dependency", disabled = true, config = function() print "my config hook" @@ -144,7 +163,7 @@ require "dep" { require "dep" { { "user/package", - requires = { "user/dependency", "user/another_dependency" }, + reqs = { "user/dependency", "user/another_dependency" }, deps = "user/dependent", disabled = true, config = function() @@ -165,10 +184,10 @@ they are combined into one just like normal package specifications. require "dep" { { "user/package", - requires = { + reqs = { { "user/dependency1", - requires = "user/dependency2" + reqs = "user/dependency2" } } } @@ -191,7 +210,7 @@ require "dep" { require "dep" { { "user/dependency1", - requires = "user/dependency2", + reqs = "user/dependency2", deps = "user/package" } } @@ -200,11 +219,11 @@ require "dep" { require "dep" { { "user/dependency1", - requires = "user/dependency2" + reqs = "user/dependency2" }, { "user/package", - requires = "user/dependency1" + reqs = "user/dependency1" } } @@ -231,11 +250,11 @@ instead of hanging or crashing. require "dep" { { "user/package1", - requires = "user/package2" + reqs = "user/package2" }, { "user/package2", - requires = "user/package1" + reqs = "user/package1" } } ``` @@ -251,12 +270,12 @@ require "dep" { { "user/package1", disabled = true, -- implied - requires = "user/dependency" + reqs = "user/dependency" }, { "user/package2", disabled = true, -- implied - requires = "user/dependency" + reqs = "user/dependency" } } ``` @@ -267,20 +286,172 @@ If a dependency fails to load for some reason, all of its dependents are guarant require "dep" { { "user/problematic", - function() + load = function() error("bad hook") end }, { "user/dependent", requires = "user/problematic", - function() + load = function() print "unreachable" end } } ``` +## Lazy loading + +Imagine you're using [telescope.nvim][7] and you need to pull it up with a keybind, +but you don't want to have it load before that moment. With lazy loading you may +choose to only load it when needed using the built in lazy utils which are made +available to you as soon as you start using the lazy option. + +```lua +require "dep" { + { "nvim-telescope/telescope.nvim", + lazy = function(load) + load:keymap("n", "f") + end, + load = function() + require("telescope").setup {} + vim.keymap.set("n", "f", require("telescope.builtin").find_files, {}) + end + } +} +``` + +Say you wanted to use [gitsigns.nvim][9], but only wanted to load it when +in a git directory OR when you call the Gitsigns command. With the power of lazy +loading this can be accomplished by simply defining an auto command like so: + +```lua +require "dep" { + { + "lewis6991/gitsigns.nvim", + lazy = function(load) + -- load gitsigns if we're in a git repository + load:auto({ "BufEnter", "BufNew" }, { + callback = function() + local paths = vim.fs.find({ ".git", }, { upward = true }) + if #paths > 0 then + load:cleanup() + end + end + }) + + -- load gitsigns if the user trys to run the command + load:cmd("Gitsigns") + end, + load = function() + require("gitsigns").setup {} + end + } +} + +``` + +If you're in the need of a deeper understanding of how the utils work go check +out `lua/lazy/loader/init.lua` for the source code. + +## Separating code into modules + +Suppose you split your `init.lua` into two files `packages/search.lua` and +`packages/vcs.lua`, which declare the packages [telescope.nvim][7] and [vim-fugitive][8] respectively. + +```lua +-- ~/.config/nvim/lua/packages/search.lua: +return { + { + "nvim-telescope/telescope.nvim", + reqs = "nvim-lua/plenary.nvim" + } +} +``` + +```lua +-- ~/.config/nvim/lua/packages/vcs.lua: +return { + "tpope/vim-fugitive" +} +``` + +Package specifications from other modules can be loaded using the `modules` option. + +```lua +require "dep" { + modules = { + prefix = "packages" + } +} + +-- the above is equivalent to +require "dep" { + modules = { + prefix = "packages.", + "search", + "vcs" + } +} + +-- or +require "dep" { + modules = { + "packages.search", + "packages.vcs" + } +} + +-- which is equivalent to +local packages = {} + +for _, package in ipairs(require "packages.search") do + table.insert(packages, package) +end + +for _, package in ipairs(require "packages.vcs") do + table.insert(packages, package) +end + +require("dep")(packages) + +-- which is ultimately equivalent to +require "dep" { + { + "nvim-telescope/telescope.nvim", + reqs = "nvim-lua/plenary.nvim" + }, + "tpope/vim-fugitive" +} + +-- all of the above are guaranteed to load plenary.nvim before telescope.nvim. +-- order of telescope.nvim and vim-fugitive is consistent but unspecified. +``` + +Entire modules can be marked as disabled, which disables all top-level packages declared in that module. + +```lua +return { + disable = true, + { + "user/package", + disabled = true, -- implied by module + reqs = { + { + "user/dependency", + -- disabled = true -- not implied + } + }, + deps = { + { + "user/dependent", + disabled = true -- implied by dependency + } + } + } +} +``` + ## Miscellaneous configuration dep accepts configuration parameters as named fields in the package list. @@ -293,21 +464,43 @@ require "dep" { -- "always": synchronize all packages on startup sync = "new", - -- [function] Callback when dep is (re)loaded - -- if a table is returned it will be read as a table of config specs - load = function() - end + -- [array] Specifies the modules to load package specifications from. + -- Defaults to an empty table. + -- Items can be either an array of package specifications, + -- or a string that indicates the name of the module from which the array of package specifications is loaded. + modules = { + -- [string] Prefix string to prepend to all module names. + prefix = "", + }, -- list of package specs... } ``` +## Known Issues + +- Lazy loading nvim-cmp doesn't work as the external sources don't get reconized +by nvim-cmp when it's loaded. + +## Contributing + +When contributing you may choose to run tests before commiting changes, if that +is so you may choose to run the following: +```sh +git config core.hooksPath .githooks +``` + ## License dep is licensed under the [MIT License](LICENSE). -[1]: https://neovim.io/ -[2]: https://www.lua.org/ -[3]: https://github.com/luaneko -[4]: https://git-scm.com/ -[5]: https://git.squi.bid/nvim +[1]: https://chiya.dev/posts/2021-11-27-why-package-manager +[2]: https://neovim.io/ +[3]: https://www.lua.org/ +[4]: https://github.com/luaneko +[5]: https://squi.bid +[6]: https://git-scm.com/ +[7]: https://github.com/nvim-telescope/telescope.nvim +[8]: https://github.com/tpope/vim-fugitive +[9]: https://github.com/lewis6991/gitsigns.nvim +[10]: https://git.squi.bid/nvim diff --git a/doc/dep.txt b/doc/dep.txt new file mode 100644 index 0000000..be5151d --- /dev/null +++ b/doc/dep.txt @@ -0,0 +1,670 @@ +*dep.txt* Declarative Package Manager 02-Jul-2025 + +============================================================================== +Table of Contents *dep-table-of-contents* + +1. Introduction |dep| +2. Setup |dep-setup| +3. Specs |dep-spec| + - Package Spec |dep-package-spec| + - Module Spec |dep-module-spec| +4. Lazy Loading |dep-lazy-loading| + - Lazy Loading API |dep-lazy-loading-api| + - Lazy Loading API Shorthands |dep-lazy-loading-api-shorthands| +5. Commands |dep-commands| +6. Examples |dep-examples| + - Declaring Dependencies |dep-examples-declaring-dependencies| + - Modules |dep-examples-modules| + - Lazy Loading |dep-examples-lazy-loading| +7. Credits & License |dep-credits| + +============================================================================== +1. Introduction *dep* + +A versatile, declarative and correct neovim package manager in Lua. Originally +written for personal use by luaneko. Adapted by squibid for general use. + +What does that mean? + +1. `versatile` - packages can be declared in any Lua file in any order of your +liking. +2. `declarative` - packages are declared using simple Lua tables. +3. `correct` - packages are always loaded in a correct and consistent order +(barring any lazy loading). + +In addition to the above dep has been built to be completely in control of you, +the user. With the help of lazy loading you can choose when your plugin loads +down to the finest detail (examples may be found below). + +============================================================================== +2. Setup *dep-setup* + +Put the following anywhere before any actual use of dep. + +>lua + local path = vim.fn.stdpath("data") .. "/site/pack/deps/opt/dep" + + if vim.fn.empty(vim.fn.glob(path)) > 0 then + vim.fn.system({ + "git", + "clone", + "--depth=1", + "https://git.squi.bid/squibid/dep", + path, + }) + end + + vim.cmd("packadd dep") +< + +============================================================================== +3. Specs *dep-spec* + +dep uses a variation of specifications to ensure everything works smoothly. +This includes a basic spec used when setting up dep: +>lua + require("dep") { + -- [string] Specifies when dep should automatically synchronize. + -- "never": disable this behavior + -- "new": only install newly declared packages (default) + -- "always": synchronize all packages on startup + sync = "new", + + -- [array] Specifies the modules to load package specifications from. + -- Defaults to an empty table. + -- Items can be either an array of package specifications, + -- or a string that indicates the name of the module from which the array + -- of package specifications is loaded. + -- + -- "."'s are added between the prefix and module names as required. In + -- addition if there is only a prefix and no modules supplied then dep + -- automatically loads all lua files in the directory. + modules = { + -- [string] Prefix string to prepend to all module names. This is + -- optional. + prefix = "", + + -- [string] module names + ... + }, + + -- [table|string] package specification(s) + ... + } +< +PACKAGE SPEC *dep-package-spec* +>lua + { + -- [string] Specifies the full name of the package. + -- This is the only required field; all other fields are optional. + "user/package", + + -- [function] Code to run before the package is loaded into neovim. + setup = function() + vim.g.package_config = ... + end, + + -- [function] Code to run after the package is loaded into neovim. + load = function() + require "package".setup(...) + end, + + -- [function] Code to run after the package is installed or updated. + config = function() + os.execute(...) + end, + + -- [function|true] Code used to determine when the package should be + -- loaded. + lazy = function(load) + load:cmd("LoadPackage") + end, + + -- [string] Overrides the short name of the package. + -- Defaults to a substring of the full name after '/'. + as = "custom_package", + + -- [string] Overrides the URL of the git repository to clone. + -- Defaults to "https://github.com/{full_name}.git". + url = "https://git.squi.bid/user/package.git", + + -- [string] Overrides the source in which the package is gotten + -- from. This is not set by default. + path = "~/my-local-package/", + + -- [string] Overrides the name of the branch to clone. + -- Defaults to whatever the remote configured as their HEAD, which is + -- usually "master". + branch = "develop", + + -- [string] Overrides the commit ref to target + -- Defaults to the latest commit on the current branch + commit = "e76cb03", + + -- [boolean] Prevents the package from being loaded. + disable = true, + + -- [boolean] Prevents the package from being updated. + pin = true, + + -- [string|array] Specifies requirements that must be loaded before the + -- package. If given a string, it is wrapped into an array. + reqs = {...}, + + -- [string|array] Specifies dependents that must be loaded after the + -- package. If given a string, it is wrapped into an array. + deps = {...} + } +< +MODULE SPEC *dep-module-spec* +>lua + { + -- [string] Specifies a name for the module + name = "a name", + + -- [string] Specifies a description of the module + desc = "a description of the module", + + -- [boolean] Prevents all packages in the module from being loaded. + disable = false, + + -- [table|string] package specification(s) + ... + } +< +More information on the package specifications may be found in +|dep-package-spec|. + +============================================================================== +4. Lazy Loading *dep-lazy-loading* + +Lazy loading is important for making sure neovim can load nice and fast unlike +a certain bloated IDE. It has a seperate section in this documentation to +ensure that you can use it to it's full extent. + +If you refer to the |dep-package-spec| you'll notice the `lazy` flag which may +be used to conditionally load a package. When it is set to a function you +choose when it runs and more information on that may be found in +|dep-lazy-loading-api|. In addition to setting it to a function you may set it +to `true` in which case dep takes care of loading it for you. + +When setting a colorscheme dep checks to make sure that the plugin is loaded, +therefore it's recommended that you make use of the `lazy` flags ability to be +set to `true` by setting any colorscheme that you have installed, but do not +use as your main one to lazy. + +LAZY LOADING API *dep-lazy-loading-api* + +Within the |dep-package-spec| the lazy flag when set to a function takes one +argument `load` which is a class containing loading functions. For the +following examples assume that `load` is set to the class which may be found +within `lua/dep/lazy/loader/init.lua`. + +------------------------------------------------------------------------------ +LOAD:CMD *dep-lazy-loading-api-cmd* + +`load:cmd` is a function which allows you to specify a command for the package +to load on. It takes the similar arguments to |nvim_create_user_command()| +with a key difference in what the command runs. The following is an example of +what arguments the function takes: +>lua + load:cmd("Command", {}) +< +Notice the missing 'command' argument which is found in +|nvim_create_user_command|, this is replaced by a callback function. The above +is equivalent to the following: +>lua + load:cmd("Command", { + callback = function() + load:cleanup() + + if (rerun) then + vim.cmd("Command") + end + end + }) +< +If you wish the second argument may be completely ommitted. Note the inclusion +of a `rerun` field, this is a parameter which may be passed into the options table +to re-run the binding after loading the package. You may choose to disable the +built-in logic by passing false. + +------------------------------------------------------------------------------ +LOAD:AUTO *dep-lazy-loading-api-auto* + +`load:auto` is a function which allows you to specify an auto command for the +package to load on. It takes the same arguments as |nvim_create_autocmd()|. +The following is an example of using it: +>lua + load:auto("InsertEnter", {}) +< +Just like with |nvim_create_autocmd()| you may choose to pass in a 'callback' +by default the above is equivalent to the following: +>lua + load:auto("InsertEnter", { + callback = function() + load:cleanup() + end + }) +< +As with `load:cmd` the second argument may be ommitted. + +------------------------------------------------------------------------------ +LOAD:FT *dep-lazy-loading-api-ft* + +`load:ft` is a function which allows you to specify a filetype for the package +to load on. It takes one argument: 'filetype' like so: +>lua + load:ft("lua") +< +Which is equivalent to the following: +>lua + load:auto("FileType", { + pattern = "lua", + callback = function() + load:cleanup() + end + }) +< +Note that this is just an expansion of `load:auto` for your convenience. + +------------------------------------------------------------------------------ +LOAD:KEYMAP *dep-lazy-loading-api-keymap* + +`load:keymap` is a function which allows you to specify a keymap for the +package to load on. It takes the similar arguments to |vim.keymap.set()| with a +key difference in what the command runs. The following is an example of what +arguments the function takes: +>lua + load:keymap("n", "p", {}) +< +Notice the missing 'rhs' argument which is found in |vim.keymap.set|, this is +replaced by a callback function. The above is equivalent to the following: +>lua + load:keymap("n", "p", { + callback = function() + -- register keymap unload + load:cleanup() + + -- call the keymap after the user has mapped it + if type(rerun) == "function" then + rerun() + elseif rerun then + local keys = vim.api.nvim_replace_termcodes(bind, true, false, true) + vim.api.nvim_input(keys) + end + end + }) +< +Note the inclusion of a `rerun` field, this is a parameter which may be passed +into the options table to re-run the binding after loading the package. You +may choose to include your own logic by passing a function to the `rerun` +field or disable the built-in logic by passing false. + +------------------------------------------------------------------------------ +LOAD:PLUGIN *dep-lazy-loading-api-plugin* + +`load:plugin` is a function which allows you to specify another plugin for the +package to load after. It takes two arguments: `plugin` which is the name of +the plugin you want to follow like: 'user/package'. The second argument is +`opts` which is a table with one option: `callback` which is a function. The +following is an example: +>lua + load:plugin("user/package", {}) +< +Which is the same as: +>lua + load:plugin("user/package", { + callback = function() + self:cleanup() + end + }) +< +When 'user/package' is already loaded the `callback` is called immediately. + +LAZY LOADING API SHORTHANDS *dep-lazy-loading-api-shorthands* + +On occasion you may wish to only define one condition for the package to load. +When that is the case you may choose to use the built-in shorthands. By +loading them: +>lua + require("dep.lazy.loader.short") +< +The shorthands are very similar to those found in |dep-lazy-loading-api| with +a key exception: instead of running the functions within the body of a +function set as the lazy field to a package specification this is the lazy +field and may be use like so: +>lua + { "user/package", + lazy = require("dep.lazy.loader.short").cmd("Command") + } +< +And you may of course put the shorthands in a variable to make this actually +shorter: +>lua + local short = require("dep.lazy.loader.short") + { "user/package", + lazy = short.cmd("Command") + } +< +============================================================================== +5. Commands *dep-commands* + +------------------------------------------------------------------------------ +SYNC ALL PLUGINS *:DepSync* +- installs new packages, updates packages to the latest versions, + cleans removed packages and reloads packages as necessary. + +------------------------------------------------------------------------------ +CLEAN REMOVED PLUGINS *:DepClean* +- cleans removed packages. + +------------------------------------------------------------------------------ +RELOAD ALL PLUGINS *:DepReload* +- reloads all packages. + +------------------------------------------------------------------------------ +OPEN THE UI *:DepUi* +- opens the ui. + +------------------------------------------------------------------------------ +OPEN THE LOG *:DepLog* +- opens the log file. + +============================================================================== +6. Examples *dep-examples* + +When a string is given where a package specification table is expected, it is +assumed to be the package's full name. + +>lua + require("dep") { + -- these two are equivalent + "user/package", + { "user/package" }, + } +< + +A package can be declared multiple times. Multiple declarations of the same +package are combined into one. This is useful when declaring dependencies, +which is explored later. + +>lua + require("dep") { + { "user/package", + reqs = "user/dependency", + disabled = true, + config = function() + print("my config hook") + end + }, + { "user/package", + requires = "user/another_dependency", + deps = "user/dependent", + disabled = false, + config = function() + os.execute("make") + end + } + } + + -- the above is equivalent to + require("dep") { + { "user/package", + reqs = { "user/dependency", "user/another_dependency" }, + deps = "user/dependent", + disabled = true, + config = function() + print("my config hook") + os.execute("make") + end + } + } +< +DECLARING DEPENDENCIES *dep-examples-declaring-dependencies* + +The dependencies and dependents declared in a package specification are +themselves package specifications. If a dependency or dependent is declared +multiple times, they are combined into one just like normal package +specifications. +>lua + require("dep") { + { "user/package", + reqs = { + { "user/dependency1", + reqs = "user/dependency2" + } + } + } + } + + -- the above is equivalent to + require("dep") { + { "user/dependency2", + deps = { + { "user/dependency1", + deps = "user/package" + } + } + } + } + + -- which is equivalent to + require("dep") { + { "user/dependency1", + reqs = "user/dependency2", + deps = "user/package" + } + } + + -- which is equivalent to + require("dep") { + { "user/dependency1", + reqs = "user/dependency2" + }, + { "user/package", + reqs = "user/dependency1" + } + } + + -- which is equivalent to + require("dep") { + { "user/dependency2", + deps = "user/dependency1" + }, + { "user/dependency1", + deps = "user/package" + } + } + + -- all of the above are guaranteed to load in the following order: + -- dependency2, dependency1, package +< +If dep detects a circular dependency cycle, it reports the problematic packages +instead of hanging or crashing. +>lua + -- this throws an error saying package1 depends on package2 which depends on + -- package1 + require("dep") { + { "user/package1", + reqs = "user/package2" + }, + { "user/package2", + reqs = "user/package1" + } + } +< +A dependency can be marked as disabled, which disables all dependents +automatically. +>lua + require("dep") { + { "user/dependency", + disabled = true + }, + { "user/package1", + disabled = true, -- implied + reqs = "user/dependency" + }, + { "user/package2", + disabled = true, -- implied + reqs = "user/dependency" + } + } +< +If a dependency fails to load for some reason, all of its dependents are +guaranteed to not load. +>lua + require("dep") { + { "user/problematic", + load = function() + error("bad hook") + end + }, + { "user/dependent", + requires = "user/problematic", + load = function() + print "unreachable" + end + } + } +< + +MODULES *dep-examples-modules* + +Suppose you split your `init.lua` into two files `packages/search.lua` and +`packages/vcs.lua`, which declare the packages telescope.nvim and vim-fugitive +respectively. + +>lua + -- ~/.config/nvim/lua/packages/search.lua: + return { + { "nvim-telescope/telescope.nvim", + reqs = "nvim-lua/plenary.nvim" + } + } +< +>lua + -- ~/.config/nvim/lua/packages/vcs.lua: + return { "tpope/vim-fugitive" } +< +Package specifications from other modules can be loaded using the `modules` +option. +>lua + require("dep") { + modules = { + prefix = "packages" + } + } + + -- the above is equivalent to + require("dep") { + modules = { + prefix = "packages.", + "search", + "vcs" + } + } + + -- or + require("dep") { + modules = { + "packages.search", + "packages.vcs" + } + } + + -- which is equivalent to + local packages = {} + + for _, package in ipairs(require "packages.search") do + table.insert(packages, package) + end + + for _, package in ipairs(require "packages.vcs") do + table.insert(packages, package) + end + + require("dep")(packages) + + -- which is ultimately equivalent to + require("dep") { + { "nvim-telescope/telescope.nvim", + reqs = "nvim-lua/plenary.nvim" + }, + "tpope/vim-fugitive" + } + + -- all of the above are guaranteed to load plenary.nvim before + -- telescope.nvim. order of telescope.nvim and vim-fugitive is consistent but + -- unspecified. +< +Entire modules can be marked as disabled, which disables all top-level packages +declared in that module. +>lua + return { + disable = true, + { "user/package", + disabled = true, -- implied by module + reqs = { + { "user/dependency", + -- disabled = true -- not implied + } + }, + deps = { + { "user/dependent", + disabled = true -- implied by dependency + } + } + } + } +< +LAZY LOADING *dep-examples-lazy-loading* + +Lazy loading is a very complicated topic, and therefore this part of the +documentation assumes you have experience with regular package managment. +Let's go over loading order, and how the lazy loader determines what needs to +be loaded. + +Let's say we have the following spec: +>lua + { "user/package", + lazy = true, + deps = "user/dependent" + } +< +This is the same as the following: +>lua + { "user/package", + lazy = true + }, + + { "user/dependent", + reqs = "user/package", + lazy = require("dep.lazy.loader.short").plugin("user/package") + } +< +What you're seeing is implicit lazy loading. By default dep will lazy load +dependents who are explicitly defined in the spec. Now if we we're to modify +'user/dependent' like so: +>lua + { "user/package", + lazy = true + }, + + { "user/dependent", + reqs = "user/package", + lazy = function(load) + load:plugin("user/package") + load:cmd("LoadDependent") + end + } +< +If we were to call the command `:LoadDependent` it would first load +'user/package', and then load 'user/dependent'. + +============================================================================== +7. Credits & License *dep-credits* + +dep is licensed under the MIT License. Check the LICENSE file for more info. + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/dep.lua b/lua/dep.lua index dbf6516..9d43fe4 100644 --- a/lua/dep.lua +++ b/lua/dep.lua @@ -1,917 +1,157 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- - -local logger = require("dep.log").global -local proc = require("dep.proc") - -local initialized, perf, config_path, base_dir -local packages, root, load - -local function bench(name, code, ...) - local start = os.clock() - code(...) - perf[name] = os.clock() - start -end - -local function get_name(id) - local name = id:match("^[%w-_.]+/([%w-_.]+)$") - if name then - return name - else - error(string.format('invalid name "%s"; must be in the format "user/package"', id)) - end -end - -local function link_dependency(parent, child) - if not parent.dependents[child.id] then - parent.dependents[child.id] = child - parent.dependents[#parent.dependents + 1] = child - end - - if not child.dependencies[parent.id] then - child.dependencies[parent.id] = parent - child.dependencies[#child.dependencies + 1] = parent - end -end - -local function register(spec, overrides) - overrides = overrides or {} - - if type(spec) ~= "table" then - spec = { spec } - end - - local id = spec[1] - local package = packages[id] - - if not package then - package = { - id = id, - enabled = true, - exists = false, - added = false, - configured = false, - loaded = false, - subtree_configured = false, - subtree_loaded = false, - on_setup = {}, - on_config = {}, - on_load = {}, - dependencies = {}, -- inward edges - dependents = {}, -- outward edges - perf = {}, - } - - packages[id] = package - packages[#packages + 1] = package - end - - local prev_dir = package.dir -- optimization - - package.name = spec.as or package.name or get_name(id) - package.url = spec.url or package.url or ("https://github.com/" .. id .. ".git") - package.branch = spec.branch or package.branch - package.dir = base_dir .. package.name - package.commit = spec.commit - package.pin = overrides.pin or spec.pin or package.pin - package.enabled = not overrides.disable and not spec.disable and package.enabled - - if prev_dir ~= package.dir then - package.exists = vim.fn.isdirectory(package.dir) ~= 0 - package.configured = package.exists - end - - package.on_setup[#package.on_setup + 1] = spec.setup - package.on_config[#package.on_config + 1] = spec.config - package.on_load[#package.on_load + 1] = spec[2] - - -- every package is implicitly dependent on us, the package manager - if root and package ~= root then - link_dependency(root, package) - end - - if type(spec.requires) == "table" then - for i = 1, #spec.requires do - link_dependency(register(spec.requires[i]), package) - end - elseif spec.requires then - link_dependency(register(spec.requires), package) - end - - if type(spec.deps) == "table" then - for i = 1, #spec.deps do - link_dependency(package, register(spec.deps[i])) - end - elseif spec.deps then - link_dependency(package, register(spec.deps)) - end - - return package -end - -local function register_recursive(list, overrides) - overrides = overrides or {} - overrides = { - pin = overrides.pin or list.pin, - disable = overrides.disable or list.disable, - } - - for i = 1, #list do - local ok, err = pcall(register, list[i], overrides) - if not ok then - error(string.format("%s (spec=%s)", err, vim.inspect(list[i]))) - end - end -end - -local function sort_dependencies() - -- we don't do topological sort, packages are loaded by traversing the graph recursively - -- any sorting is fine as long as the order is consistent and predictable - local function compare(a, b) - local a_deps, b_deps = #a.dependencies, #b.dependencies - if a_deps == b_deps then - return a.id < b.id - else - return a_deps < b_deps - end - end - - table.sort(packages, compare) - - for i = 1, #packages do - table.sort(packages[i].dependencies, compare) - table.sort(packages[i].dependents, compare) - end -end - -local function find_cycle() - local index = 0 - local indexes = {} - local lowlink = {} - local stack = {} - - -- use tarjan algorithm to find circular dependencies (strongly connected components) - local function connect(package) - indexes[package.id], lowlink[package.id] = index, index - stack[#stack + 1], stack[package.id] = package, true - index = index + 1 - - for i = 1, #package.dependents do - local dependent = package.dependents[i] - - if not indexes[dependent.id] then - local cycle = connect(dependent) - if cycle then - return cycle - else - lowlink[package.id] = math.min(lowlink[package.id], lowlink[dependent.id]) - end - elseif stack[dependent.id] then - lowlink[package.id] = math.min(lowlink[package.id], indexes[dependent.id]) - end - end - - if lowlink[package.id] == indexes[package.id] then - local cycle = { package } - local node - - repeat - node = stack[#stack] - stack[#stack], stack[node.id] = nil, nil - cycle[#cycle + 1] = node - until node == package - - -- a node is by definition strongly connected to itself - -- ignore single-node components unless it explicitly specified itself as a dependency - if #cycle > 2 or package.dependents[package.id] then - return cycle - end - end - end - - for i = 1, #packages do - local package = packages[i] - - if not indexes[package.id] then - local cycle = connect(package) - if cycle then - return cycle - end - end - end -end - -local function ensure_acyclic() - local cycle = find_cycle() - - if cycle then - local names = {} - for i = 1, #cycle do - names[i] = cycle[i].id - end - error("circular dependency detected in package dependency graph: " .. table.concat(names, " -> ")) - end -end - -local function run_hooks(package, type) - local hooks = package[type] - if #hooks == 0 then - return true - end - - local start = os.clock() - - -- chdir into the package directory to make running external commands - -- from hooks easier. - local last_cwd = vim.fn.getcwd() - vim.fn.chdir(package.dir) - - for i = 1, #hooks do - local ok, err = pcall(hooks[i]) - if not ok then - vim.fn.chdir(last_cwd) - - package.error = true - return false, err - end - end - - vim.fn.chdir(last_cwd) - package.perf[type] = os.clock() - start - - logger:log( - "hook", - string.format("triggered %d %s %s for %s", #hooks, type, #hooks == 1 and "hook" or "hooks", package.id) - ) - - return true -end - -local function ensure_added(package) - if not package.added then - local ok, err = run_hooks(package, "on_setup") - if not ok then - package.error = true - return false, err - end - - local start = os.clock() - - ok, err = pcall(vim.cmd, "packadd " .. package.name) - if not ok then - package.error = true - return false, err - end - - package.added = true - package.perf.pack = os.clock() - start - - logger:log("vim", string.format("packadd completed for %s", package.id)) - end - - return true -end - -local function configure_recursive(package) - if not package.exists or not package.enabled or package.error then - return - end - - if package.subtree_configured then - return true - end - - for i = 1, #package.dependencies do - if not package.dependencies[i].configured then - return - end - end - - if not package.configured then - local ok, err = ensure_added(package) - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - ok, err = run_hooks(package, "on_config") - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - package.configured = true - logger:log("config", string.format("configured %s", package.id)) - end - - package.subtree_configured = true - - for i = 1, #package.dependents do - package.subtree_configured = configure_recursive(package.dependents[i]) and package.subtree_configured - end - - return package.subtree_configured -end - -local function load_recursive(package) - if not package.exists or not package.enabled or package.error then - return - end - - if package.subtree_loaded then - return true - end - - for i = 1, #package.dependencies do - if not package.dependencies[i].loaded then - return - end - end - - if not package.loaded then - local ok, err = ensure_added(package) - if not ok then - logger:log("error", string.format("failed to configure %s; reason: %s", package.id, err)) - return - end - - ok, err = run_hooks(package, "on_load") - if not ok then - logger:log("error", string.format("failed to load %s; reason: %s", package.id, err)) - return - end - - package.loaded = true - logger:log("load", string.format("loaded %s", package.id)) - end - - package.subtree_loaded = true - - for i = 1, #package.dependents do - package.subtree_loaded = load_recursive(package.dependents[i]) and package.subtree_loaded - end - - return package.subtree_loaded -end - -local function reload_meta() - local ok, err - bench("meta", function() - ok, err = pcall( - vim.cmd, - [[ - silent! helptags ALL - silent! UpdateRemotePlugins - ]] - ) - end) - - if ok then - logger:log("vim", "reloaded helptags and remote plugins") - else - logger:log("error", string.format("failed to reload helptags and remote plugins; reason: %s", err)) - end -end - -local function reload() - -- clear errors to retry - for i = 1, #packages do - packages[i].error = false - end - - local reloaded - reloaded = configure_recursive(root) or reloaded - reloaded = load_recursive(root) or reloaded - - if reloaded then - reload_meta() - end - - return reloaded -end - -local function reload_all() - -- recall the load function - if load and type(load) == "function" then - local ok, ret = pcall(load) - if ok and type(ret) == "table" then - register_recursive(ret) - else - logger:log("error", ret) - end - end - - for i = 1, #packages do - local package = packages[i] - package.loaded, package.subtree_loaded = false, false - end - - reload() -end - -local function clean() - vim.loop.fs_scandir( - base_dir, - vim.schedule_wrap(function(err, handle) - if err then - logger:log("error", string.format("failed to clean; reason: %s", err)) - else - local queue = {} - - while handle do - local name = vim.loop.fs_scandir_next(handle) - if name then - queue[name] = base_dir .. name - else - break - end - end - - for i = 1, #packages do - queue[packages[i].name] = nil - end - - for name, dir in pairs(queue) do - -- todo: make this async - local ok = vim.fn.delete(dir, "rf") - if ok then - logger:log("clean", string.format("deleted %s", name)) - else - logger:log("error", string.format("failed to delete %s", name)) - end - end - end - end) - ) -end - -local function mark_reconfigure(package) - local function mark_dependencies(node) - node.subtree_configured, node.subtree_loaded = false, false - - for i = 1, #node.dependencies do - mark_dependencies(node.dependencies[i]) - end - end - - local function mark_dependents(node) - node.configured, node.loaded, node.added = false, false, false - node.subtree_configured, node.subtree_loaded = false, false - - for i = 1, #node.dependents do - mark_dependents(node.dependents[i]) - end - end - - mark_dependencies(package) - mark_dependents(package) -end - -local function sync(package, cb) - if not package.enabled then - cb() - return - end - - if package.exists then - if package.pin then - cb() - return - end - - local function log_err(err) - logger:log("error", string.format("failed to update %s; reason: %s", package.id, err)) - end - - proc.git_rev_parse(package.dir, "HEAD", function(err, before) - if err then - log_err(before) - cb(err) - else - if package.commit then - proc.git_checkout(package.dir, package.branch, package.commit, function(err, message) - if err then - log_err(message) - cb(err) - else - proc.git_rev_parse(package.dir, package.commit, function(err, after) - if err then - log_err(after) - cb(err) - elseif before == after then - logger:log("skip", string.format("skipped %s", package.id)) - cb(err) - else - mark_reconfigure(package) - logger:log("update", string.format("updated %s; %s -> %s", package.id, before, after)) - end - end) - end - end) - else - proc.git_fetch(package.dir, "origin", package.branch or "HEAD", function(err, message) - if err then - log_err(message) - cb(err) - else - proc.git_rev_parse(package.dir, "FETCH_HEAD", function(err, after) - if err then - log_err(after) - cb(err) - elseif before == after then - logger:log("skip", string.format("skipped %s", package.id)) - cb(err) - else - proc.git_reset(package.dir, after, function(err, message) - if err then - log_err(message) - else - mark_reconfigure(package) - logger:log("update", string.format("updated %s; %s -> %s", package.id, before, after)) - end - - cb(err) - end) - end - end) - end - end) - end - end - end) - else - proc.git_clone(package.dir, package.url, package.branch, function(err, message) - if err then - logger:log("error", string.format("failed to install %s; reason: %s", package.id, message)) - else - if package.commit then - proc.git_checkout(package.dir, package.branch, package.commit, function(err, message) - if err then - logger:log("error", string.format("failed to checkout %s; reason: %s", package.id, message)) - else - package.exists = true - mark_reconfigure(package) - logger:log("install", string.format("installed %s", package.id)) - end - end) - else - package.exists = true - mark_reconfigure(package) - logger:log("install", string.format("installed %s", package.id)) - end - end - - cb(err) - end) - end -end - -local function sync_list(list, on_complete) +local logger = require("dep.log") +local packager = require("dep.package") +local modules = require("dep.modules") +local bench = require("dep.bench") +local lazy = require("dep.lazy") + +-- all functions for convenience +local M = {} + +--- sync a tree of plugins +---@param tree package[] tree of plugins +---@param cb function? callback +local function synctree(tree, cb) local progress = 0 - local has_errors = false - local function done(err) + ---@param spec vim.pack.Spec + ---@param path string + local function done(spec, path) + ---@type package + local p = spec.data + _ = path progress = progress + 1 - has_errors = has_errors or err - if progress == #list then - clean() - reload() - - if has_errors then - logger:log("error", "there were errors during sync; see :messages or :DepLog for more information") - else - logger:log("update", string.format("synchronized %s %s", #list, #list == 1 and "package" or "packages")) - end - - if on_complete then - on_complete() - end + local info = vim.pack.get({ spec.name }, { info = false })[1] + if info.active then + p.exists = true + p:unconfiguretree() + p:runhooks("on_config") + logger:log("config", "package: %s configured", p.id) + p.configured = true end end - for i = 1, #list do - sync(list[i], done) + -- convert our spec to vim.pack.Spec + ---@type vim.pack.Spec[] + local vimspecs = {} + for _, p in ipairs(tree) do + table.insert(vimspecs, { + name = p.name, + src = p.path or p.url, + version = p.commit or p.branch, + data = p, + }) + end + + -- install/update all packages + vim.pack.add(vimspecs, { load = done, confirm = false }) + vim.pack.update(vimspecs, { force = true }) + + -- reload all packages + for _, p in pairs(vimspecs) do + p.data:reload() + end + if cb then cb() end +end + +--- check if a package should be synced +---@param opts table options +---@param package package package table spec +---@return boolean sync +local function shouldsync(opts, package) + if opts.sync == "never" then + return false + elseif opts.sync == "new" or opts.sync == nil then + return not package.exists + else + return opts.sync == "always" end end -local function get_commits(cb) - local results = {} - local done = 0 - for i = 1, #packages do - local package = packages[i] - - if package.exists then - proc.git_rev_parse(package.dir, "HEAD", function(err, commit) - if not err then - results[package.id] = commit - end - - done = done + 1 - if done == #packages then - cb(results) - end - end) - else - done = done + 1 - end - end +--- make comparison for table.sort +---@param a package package spec a +---@param b package package spec b +---@return boolean +local function comp(a, b) + -- NOTE: this doesn't have to be in any real order, it just has to be + -- consistant, thus we can just check if the unicode value of one package + -- id is less than the other + return a.id < b.id end -local function print_list(cb) - get_commits(function(commits) - local buffer = vim.api.nvim_create_buf(true, true) - local line, indent = 0, 0 +-- basically the main function of our program +---@param opts speclist +return function(opts) + M.config_path = debug.getinfo(2, "S").source:sub(2) + logger.pipe = logger:setup() + bench.setup() + lazy.setup() - local function print(chunks) - local concat = {} - local column = 0 + -- generate doc tags + vim.cmd.helptags(vim.fn.stdpath('data')..'/site/pack/core/opt/dep/doc') - for _ = 1, indent do - concat[#concat + 1] = " " - column = column + 2 - end - - if not chunks then - chunks = {} - elseif type(chunks) == "string" then - chunks = { { chunks } } - end - - for i = 1, #chunks do - local chunk = chunks[i] - concat[#concat + 1] = chunk[1] - chunk.offset, column = column, column + #chunk[1] - end - - vim.api.nvim_buf_set_lines(buffer, line, -1, false, { table.concat(concat) }) - - for i = 1, #chunks do - local chunk = chunks[i] - if chunk[2] then - vim.api.nvim_buf_add_highlight(buffer, -1, chunk[2], line, chunk.offset, chunk.offset + #chunk[1]) - end - end - - line = line + 1 - end - - print(string.format("Installed packages (%s):", #packages)) - indent = 1 - - local loaded = {} - - local function dry_load(package) - if loaded[package.id] then + local initialized, err = pcall(function() + packager.set_base_dir(opts.base_dir or vim.fn.stdpath("data").."/site/pack/core/opt/") + bench.mark("load", function() + -- register all packages + local root = packager:new({ + "squibid/dep", + url = "https://git.squi.bid/squibid/dep.git", + branch = "pack" + }) + if not root then + logger:log("error", "couldn't register root package") return end - for i = 1, #package.dependencies do - if not loaded[package.dependencies[i].id] then - return - end + -- setup all packages and modules + if opts.modules then + modules:setup(opts, nil, M.config_path) + end + packager.register_speclist(opts) + + -- sort package dependencies + for _, package in pairs(packager.get_packages()) do + table.sort(package.requirements, comp) + table.sort(package.dependents, comp) end - loaded[package.id], loaded[#loaded + 1] = true, package - - local chunk = { - { string.format("[%s] ", commits[package.id] or " "), "Comment" }, - { package.id, "Underlined" }, - } - - if not package.exists then - chunk[#chunk + 1] = { " *not installed", "Comment" } + -- make sure there arent any circular dependencies + local ok = packager.findcycle(packager.get_packages()) + if type(ok) == "table" then + logger:log("error", "found a cycle in the package spec here: %s", vim.inspect(ok)) end - - if not package.loaded then - chunk[#chunk + 1] = { " *not loaded", "Comment" } - end - - if not package.enabled then - chunk[#chunk + 1] = { " *disabled", "Comment" } - end - - if package.pin then - chunk[#chunk + 1] = { " *pinned", "Comment" } - end - - print(chunk) - - for i = 1, #package.dependents do - dry_load(package.dependents[i]) - end - end - - dry_load(root) - indent = 0 - - print() - print("Load time (μs):") - indent = 1 - local profiles = {} - - for i = 1, #packages do - local package = packages[i] - local profile = { - package = package, - total = 0, - setup = package.perf.on_setup or 0, - load = package.perf.on_load or 0, - pack = package.perf.pack or 0, - - "total", - "setup", - "pack", - "load", - } - - if package == root then - for k, v in pairs(perf) do - if profile[k] then - profile[k] = profile[k] + v - end - end - end - - for j = 1, #profile do - profile.total = profile.total + profile[profile[j]] - end - - profiles[#profiles + 1] = profile - end - - table.sort(profiles, function(a, b) - return a.total > b.total end) - for i = 1, #profiles do - local profile = profiles[i] - local chunk = { - { "- ", "Comment" }, - { profile.package.id, "Underlined" }, - { string.rep(" ", 40 - #profile.package.id) }, - } - - for j = 1, #profile do - local key, value = profile[j], profile[profile[j]] - chunk[#chunk + 1] = { string.format(" %5s ", key), "Comment" } - chunk[#chunk + 1] = { string.format("%4d", value * 1000000) } - end - - print(chunk) + -- load packages + for _, package in pairs(packager.get_packages()) do + package:reload() end - indent = 0 - print() - print("Dependency graph:") - - local function walk_graph(package) - local chunk = { - { "| ", "Comment" }, - { package.id, "Underlined" }, - } - - local function add_edges(p) - for i = 1, #p.dependencies do - local dependency = p.dependencies[i] - - if dependency ~= root and not chunk[dependency.id] then -- don't convolute the list - chunk[#chunk + 1] = { " " .. dependency.id, "Comment" } - chunk[dependency.id] = true - add_edges(dependency) - end - end - end - - add_edges(package) - print(chunk) - - for i = 1, #package.dependents do - indent = indent + 1 - walk_graph(package.dependents[i]) - indent = indent - 1 + -- get all package that need syncing + local targets = {} + for _, package in pairs(packager.get_packages()) do + if shouldsync(opts, package) then + table.insert(targets, package) end end - walk_graph(root) - - print() - print("Debug information:") - - local debug = {} - for l in vim.inspect(packages):gmatch("[^\n]+") do - debug[#debug + 1] = l - end - - vim.api.nvim_buf_set_lines(buffer, line, -1, false, debug) - vim.api.nvim_buf_set_name(buffer, "packages.dep") - vim.api.nvim_buf_set_option(buffer, "bufhidden", "wipe") - vim.api.nvim_buf_set_option(buffer, "modifiable", false) - - vim.cmd("vsp") - vim.api.nvim_win_set_buf(0, buffer) - - if cb then - cb() - end + -- install all targets + synctree(targets) end) -end -vim.cmd([[ - command! DepSync lua require("dep").sync() - command! DepReload lua require("dep").reload() - command! DepClean lua require("dep").clean() - command! DepList lua require("dep").list() - command! DepLog lua require("dep").open_log() - command! DepConfig lua require("dep").open_config() -]]) - -local function wrap_api(name, fn) - return function(...) - if initialized then - local ok, err = pcall(fn, ...) - if not ok then - logger:log("error", err) - end - else - logger:log("error", string.format("cannot call %s; dep is not initialized", name)) - end + if not initialized then + logger:log("error", err) end -end ---todo: prevent multiple execution of async routines -return setmetatable({ - sync = wrap_api("dep.sync", function(on_complete) - sync_list(packages, on_complete) - end), + -- add some user commands + vim.api.nvim_create_user_command("DepSync", function() + synctree(packager.get_packages()) + end, {}) - reload = wrap_api("dep.reload", reload_all), - clean = wrap_api("dep.clean", clean), - list = wrap_api("dep.list", print_list), - - open_log = wrap_api("dep.open_log", function() - vim.cmd("vsp " .. logger.path) - end), - - open_config = wrap_api("dep.open_config", function() - vim.cmd("vsp " .. config_path) - end), -}, { - __call = function(_, config) - local err - perf = {} - config_path = debug.getinfo(2, "S").source:sub(2) - - initialized, err = pcall(function() - base_dir = config.base_dir or (vim.fn.stdpath("data") .. "/site/pack/deps/opt/") - packages = {} - - bench("load", function() - root = register("squibid/dep") - if config["load"] and type(config["load"]) == "function" then - local ok, ret = pcall(config["load"]) - if ok and type(ret) == "table" then - load = config["load"] - register_recursive(ret) - else - logger:log("error", ret) - end - end - register_recursive(config) - sort_dependencies() - ensure_acyclic() - end) - - reload() - - local should_sync = function(package) - if config.sync == "new" or config.sync == nil then - return not package.exists - else - return config.sync == "always" - end - end - - local targets = {} - - for i = 1, #packages do - local package = packages[i] - if should_sync(package) then - targets[#targets + 1] = package - end - end - - sync_list(targets) - end) - - if not initialized then - logger:log("error", err) + vim.api.nvim_create_user_command("DepReload", function() + for _, package in pairs(packager.get_packages()) do + package:reload() end - end, -}) + end, {}) +end diff --git a/lua/dep/bench.lua b/lua/dep/bench.lua new file mode 100644 index 0000000..e8b2135 --- /dev/null +++ b/lua/dep/bench.lua @@ -0,0 +1,32 @@ +-- TODO: actually use this (ideally make a view that shows startuptime and +-- which plugins are currently loaded) +-- performance logging + +---@class bench +---@field perf number[] list of all perfs +local bench = {} +local b + +function bench.setup() + local o = {} + + o.perf = {} + o.inited = true + + b = o +end + +--- benchmark a peice of code +---@param name string the name of the benchmark +---@param f function the code to benchmark +---@vararg any args for f +---@return any ret the result of f +function bench.mark(name, f, ...) + local start = os.clock() + local ret = f(...) + b.perf[name] = os.clock() - start + + return ret +end + +return bench diff --git a/lua/dep/lazy/init.lua b/lua/dep/lazy/init.lua new file mode 100644 index 0000000..a0237d6 --- /dev/null +++ b/lua/dep/lazy/init.lua @@ -0,0 +1,36 @@ +local packager = require("dep.package") + +---@class lazy +local lazy = {} + +-- since this is already a ridiculous "optimization" we should really be caching +-- the results of this for when the user keeps on loading the colorscheme that +-- they've lazy loaded, that way we speed up the lazy loading process +local function colorscheme() + -- if a colorscheme doesn't exist attempt load it prior to it being set + vim.api.nvim_create_autocmd("ColorschemePre", { + pattern = vim.fn.getcompletion("", "color"), + callback = function(e) + for _, p in pairs(packager.get_packages()) do + if not p.loaded then + for _, ext in ipairs({ ".lua", ".vim" }) do + local path = p.dir.."/colors/"..e.match..ext + if vim.uv.fs_stat(path) then + p:ensureadded(true) + -- break out of here, we've loaded the colorscheme + return + end + end + end + end + end + }) +end + +--- setup all lazy handlers +function lazy.setup() + -- start the colorscheme watcher + colorscheme() +end + +return lazy diff --git a/lua/dep/lazy/loader/init.lua b/lua/dep/lazy/loader/init.lua new file mode 100644 index 0000000..0603aa1 --- /dev/null +++ b/lua/dep/lazy/loader/init.lua @@ -0,0 +1,174 @@ +local logger = require('dep.log') +local packager = require('dep.package') + +---@class lazy_loader +---@field load function the function to load the plugin +---@field command_ids string[] the commands that have been registered +---@field auto_ids number[] the auto commands that have been registered +---@field keybind_ids table[] the keybinds that have been registered +---@field plugin_ids table[] the plugins that have been registered +local lazy_loader = {} + +--- create a new instance of lazy +---@return lazy +function lazy_loader:new() + local o = {} + + setmetatable(o, self) + o.command_ids = {} + o.auto_ids = {} + o.keybind_ids = {} + o.plugin_ids = {} + + self.__index = self + + return o +end + +--- set the loading callback +---@param load function the loader function +function lazy_loader:set_load(load) + self.load = load +end + +--- create a usercommand which will trigger the plugin to load +---@param name string the name of the command +---@param opts vim.api.keyset.user_command? options +function lazy_loader:cmd(name, opts) + opts = opts or {} + + -- move the rerun arg to a seperate variable because keymap.set doesn't like + -- options it doesn't know of + local rerun = opts["rerun"] or true + opts['rerun'] = nil + + -- load the plugin on completion + if not opts["complete"] then + opts["complete"] = function(_, line, _) + self:cleanup() + + -- return all completions for the current input, we need this to ensure + -- that the new completions are loaded from the actual plugin, not our + -- definiton of the command + return vim.fn.getcompletion(line, "cmdline") + end + opts["nargs"] = "*" + end + + vim.api.nvim_create_user_command(name, opts['callback'] or function(_) + self:cleanup() + + -- attempt to rerun the command + if not rerun then + pcall(vim.cmd, name) + end + end, opts) + + table.insert(self.command_ids, name) +end + +--- create an auto command which will trigger the plugin to load +---@param event string the event to trigger on +---@param opts vim.api.keyset.create_autocmd? options +function lazy_loader:auto(event, opts) + opts = opts or {} + + opts['callback'] = opts['callback'] or function() + self:cleanup() + end + + -- create the auto command and save it + table.insert(self.auto_ids, vim.api.nvim_create_autocmd(event, opts)) +end + +--- create an auto command which will trigger on filetype +---@param filetype string filetype to register the auto on +function lazy_loader:ft(filetype) + self:auto("FileType", { + pattern = filetype + }) +end + +---@class lazy.Opts: vim.keymap.set.Opts +---@field rerun boolean|function weather to rerun and what to do + +--- create a keybind which will trigger the plugin to load +---@param mode string the mode to trigger in +---@param bind string the binding to use +---@param opts lazy.Opts? options +function lazy_loader:keymap(mode, bind, opts) + opts = opts or {} + + -- move the rerun arg to a seperate variable because keymap.set doesn't like + -- options it doesn't know of + local rerun = opts['rerun'] or true + opts['rerun'] = nil + + vim.keymap.set(mode, bind, opts['callback'] or function() + -- register keymap unload + self:cleanup() + + -- call the keymap after the user has mapped it + if type(rerun) == "function" then + rerun() + elseif rerun then + local keys = vim.api.nvim_replace_termcodes(bind, true, false, true) + vim.api.nvim_input(keys) + end + end, opts) + + table.insert(self.keybind_ids, { ['mode'] = mode, ['bind'] = bind }) +end + +--- load a plugin when another plugin loads +---@param plugin string plugin name +---@param opts table? options +function lazy_loader:plugin(plugin, opts) + opts = opts or {} + opts["callback"] = opts["callback"] or function() + self:cleanup() + end + + if packager.get_packages()[plugin].loaded then + opts["callback"]() + else + local on_load = packager.get_packages()[plugin].on_load + local on_load_idx = #on_load + 1 + on_load[on_load_idx] = opts["callback"] + + table.insert(self.plugin_ids, { plugin, on_load_idx }) + end +end + +--- cleanup all the callbacks, and load the plugin +function lazy_loader:cleanup() + -- cleanup user commands + for _, command_id in ipairs(self.command_ids) do + local ok, err = pcall(vim.api.nvim_del_user_command, command_id) + if not ok then + logger:log("lazy", err or "failed to delete user command") + end + end + -- cleanup auto commands + for _, auto_id in ipairs(self.auto_ids) do + local ok, err = pcall(vim.api.nvim_del_autocmd, auto_id) + if not ok then + logger:log("lazy", err or "failed to delete auto command") + end + end + -- cleanup keymaps + for _, keybind_id in ipairs(self.keybind_ids) do + local ok, err = pcall(vim.keymap.del, keybind_id.mode, keybind_id.bind, {}) + if not ok then + logger:log("lazy", err or "failed to delete keymap") + end + end + -- cleanup plugins + for _, plugin_id in ipairs(self.plugin_ids) do + table.remove(packager.get_packages()[plugin_id[1]].on_load, plugin_id[2]) + end + -- load the plugin + self:load() +end + +return lazy_loader diff --git a/lua/dep/lazy/loader/short.lua b/lua/dep/lazy/loader/short.lua new file mode 100644 index 0000000..e7c74f2 --- /dev/null +++ b/lua/dep/lazy/loader/short.lua @@ -0,0 +1,72 @@ +-- This file contains shorthands which rely on the loader core functions. They +-- are intended to ease lazy loading condition definitions to use them you may +-- do the following: +-- +-- ```lua +-- _G.dep_short = require("dep.lazy.loader.short") +-- ``` +-- +-- Which will allow you to reference it anywhere in your config like so: +-- +-- ```lua +-- require("dep") { +-- { "some/plugin", +-- lazy = dep_short.cmd("TheCommand") +-- } +-- } +-- ``` +-- +-- Happy vimming o/ +local short = {} + +--- create a single command +---@param name string the name of the command +---@param opts vim.api.keyset.user_command? options +---@return function callback +function short.cmd(name, opts) + return function(load) + load:cmd(name, opts) + end +end + +--- create a single auto command +---@param event string the event to trigger on +---@param opts vim.api.keyset.create_autocmd? options +---@return function callback +function short.auto(event, opts) + return function(load) + load:auto(event, opts) + end +end + +--- create a single auto command which will trigger on filetype +---@param filetype string filetype to register the auto on +---@return function callback +function short.ft(filetype) + return function(load) + load:ft(filetype) + end +end + +--- create a single keybind +---@param mode string the mode to trigger in +---@param bind string the binding to use +---@param opts lazy.Opts? options +---@return function callback +function short.keymap(mode, bind, opts) + return function(load) + load:keymap(mode, bind, opts) + end +end + +--- create a single plugin load event for when another plugin loads +---@param plugin string plugin name +---@param opts table? options +---@return function callback +function short.plugin(plugin, opts) + return function(load) + load:plugin(plugin, opts) + end +end + +return short diff --git a/lua/dep/log.lua b/lua/dep/log.lua index 86994b6..c869caf 100644 --- a/lua/dep/log.lua +++ b/lua/dep/log.lua @@ -1,23 +1,28 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local vim, setmetatable, pcall, debug, string, os, assert = vim, setmetatable, pcall, debug, string, os, assert +local logger = {} +logger.stage_colors = { + skip = "Comment", + clean = "Boolean", + install = "MoreMsg", + update = "WarningMsg", + delete = "Directory", + error = "ErrorMsg", +} + +--- create the default logging path +---@return string path to the logfile local function default_log_path() - -- ensure cache directory exists (#5) + -- create cache directory and chmod it if it doesn't already exist local path = vim.fn.stdpath("cache") - if not vim.loop.fs_stat(path) then - vim.loop.fs_mkdir(path, 0x1ff) -- 0777 + if not vim.uv.fs_stat(path) then + vim.uv.fs_mkdir(path, 0x1ff) -- 0777 end - return path .. "/dep.log" + return vim.fs.normalize(path).."/dep.log" end +--- attempt to format a string +---@vararg string formating args local function try_format(...) local ok, s = pcall(string.format, ...) if ok then @@ -25,85 +30,71 @@ local function try_format(...) end end ---- Writes logs to a file and prints pretty status messages. -local Logger = setmetatable({ - __metatable = "Logger", - __index = { - --- Prints a message associated with a stage. - log = function(self, stage, message, ...) - -- calling function - local source = debug.getinfo(2, "Sl").short_src +--- setup all logging stuff +---@param path string|nil optional alternative path for the log file +---@return table +function logger:setup(path) + logger.path = path or default_log_path() + local pipe - -- format or stringify message - if type(message) == "string" then - message = try_format(message, ...) or message - else - message = vim.inspect(message) + logger.handle = assert(vim.uv.fs_open(logger.path, "w", 0x1a4)) -- 0644 + pipe = vim.uv.new_pipe() --[[@as uv.uv_pipe_t]] + pipe:open(logger.handle) + + return pipe +end + +--- log a message +---@param level string error level +---@param message any string message to send +---@vararg any options to go into the message +function logger:log(level, message, ...) + -- make sure the message string is actually a string, and formatted + -- appropriately + if type(message) == "string" then + message = try_format(message, ...) or message + else + message = vim.inspect(message) + end + + -- get debug info about the current function + local source = debug.getinfo(2, "Sl") + + -- schedule a log message to be sent to vim, and the log file + vim.schedule(function() + if not logger.silent then + if level == "error" then + vim.api.nvim_echo({ { string.format("[dep] %s", message) } }, true, + { err = true }) + elseif logger.stage_colors[level] then + vim.api.nvim_echo({ + { "[dep]", "Identifier" }, + { " " }, + { message, logger.stage_colors[level] }, + }, true, {}) end + end - -- print and write must be done on the main event loop - vim.schedule(function() - if not self.silent then - if stage == "error" then - vim.api.nvim_err_writeln(string.format("[dep] %s", message)) - elseif self.stage_colors[stage] then - vim.api.nvim_echo({ - { "[dep]", "Identifier" }, - { " " }, - { message, self.stage_colors[stage] }, - }, true, {}) - end - end + -- write to the pipe if it's open + if logger.pipe then + logger.pipe:write(string.format("[%s] %s:%s:(%s) %s\n", os.date("%T"), + source.short_src:gsub('.*%/', ''), source.currentline, level, message)) + end + end) +end - if self.pipe then - self.pipe:write(string.format("[%s] %s: %s\n", os.date(), source, message)) - end - end) - end, +--- cleanup all logging stuff +---@param pipe table? pipe +---@param handle table? handle +function logger:cleanup(pipe, handle) + if pipe then + pipe:close() + pipe = nil + end + if handle then + vim.uv.fs_close(logger.handle) + handle = nil + end +end - --- Closes the log file handle. - close = function(self) - if self.pipe then - self.pipe:close() - self.pipe = nil - end - - if self.handle then - vim.loop.fs_close(self.handle) - self.handle = nil - end - end, - }, -}, { - --- Constructs a new `Logger`. - __call = function(mt, path) - path = path or default_log_path() - - -- clear and open log file - local handle = assert(vim.loop.fs_open(path, "w", 0x1a4)) -- 0644 - local pipe = vim.loop.new_pipe() - pipe:open(handle) - - return setmetatable({ - path = path, - handle = handle, - pipe = pipe, - silent = false, - - -- TODO: This looks good for me ;) but it should have proper vim color mapping for other people. - stage_colors = { - skip = "Comment", - clean = "Boolean", - install = "MoreMsg", - update = "WarningMsg", - delete = "Directory", - error = "ErrorMsg", - }, - }, mt) - end, -}) - -return { - Logger = Logger, - global = Logger(), -} +return logger diff --git a/lua/dep/modules/init.lua b/lua/dep/modules/init.lua new file mode 100644 index 0000000..f34fba5 --- /dev/null +++ b/lua/dep/modules/init.lua @@ -0,0 +1,74 @@ +local logger = require('dep.log') +local module = require("dep.modules.module") + +---@class modules +---@field modules module[] all modules in dep +local modules = {} + +--- Initialize all the modules +---@param self table? +---@param speclist table +---@param overrides spec? overrides +---@return modules modules manager +---@nodisacard +function modules:setup(speclist, overrides, config_path) + overrides = overrides or {} + + local o = {} + self = {} + self.__index = self + setmetatable(o, self) + + -- create a list of modules + o.modules = {} + + if (speclist.modules[1] == "*" or #speclist.modules == 0) + and speclist.modules.prefix then + + local path = vim.fs.joinpath(config_path:gsub("[^/]*$", ""), + "lua", (speclist.modules.prefix:gsub("%.", "/")) + ) + + local handle = vim.uv.fs_scandir(path) + while handle do + local name = vim.uv.fs_scandir_next(handle) + if name then + -- skip non-lua files + if name:sub(#name - 3) ~= ".lua" then + goto continue + end + + -- remove the file extension from the name so that lua doesn't fail + -- when attempting to load it + name = name:sub(0, #name - 4) + + -- put the module into the list of modules + table.insert(speclist.modules, name) + + ::continue:: + elseif name == nil then + -- no more entries + break + else + -- if there's a single error bail out + logger:log("error", "failed to run clean uv.fs_scandir_next failed") + break + end + end + end + + -- loop through all modules and initialize them + for _, modpath in ipairs(speclist.modules) do + local mod = module.new(nil, modpath, speclist.modules.prefix, overrides) + if not mod then + goto continue + end + + table.insert(o.modules, mod) + ::continue:: + end + + return self +end + +return modules diff --git a/lua/dep/modules/module.lua b/lua/dep/modules/module.lua new file mode 100644 index 0000000..a16ddf6 --- /dev/null +++ b/lua/dep/modules/module.lua @@ -0,0 +1,73 @@ +local logger = require('dep.log') +local spec_man = require("dep.spec") +local packager = require("dep.package") + +---@class module +---@field name string name of the module +---@field desc string description of the module +---@field disable boolean weather to disable all the packages inside the module +---@field path string path to the module +---@field mod table the module +---@field packages package[] all packages registed from the module +local module = {} + +--- Initialize a module +---@param self table? +---@param modpath string path to the module +---@param prefix string? the prefix to all modules +---@param overrides spec? a module override +---@return module|false module false on failure to load module +---@nodiscard +function module:new(modpath, prefix, overrides) + overrides = overrides or {} + + local ok, err + local o = {} + self = {} + self.__index = self + setmetatable(o, self) + + o.name = "" + o.desc = "" + if type(modpath) == "string" then + if prefix ~= nil then + if prefix:sub(#prefix) ~= "." and modpath:sub(1, 2) ~= "." then + modpath = "."..modpath + end + o.path = prefix..modpath + else + o.path = modpath + end + o.name = modpath + ok, o.mod = pcall(require, o.path) + if not ok then + logger:log("error", "failed to load module: %s", vim.inspect(o.mod)) + return false + end + end + o.name = o.mod.name or o.name + o.desc = o.mod.desc or o.desc + + -- ensure the overrides are properly set + overrides = vim.tbl_extend("force", overrides, { + disable = o.mod.disable or overrides.disable + }) + + -- allow a module to be a spec + if spec_man.check(o.mod, true) ~= false then + o.mod = { o.mod } + end + + ok, err = pcall(packager.register_speclist, o.mod, overrides) + if not ok then + logger:log("error", "%s <- %s", err, o.name) + return false + end + + -- ensure that the module contains the packages that it's created + self.packages = err + + return self +end + +return module diff --git a/lua/dep/package.lua b/lua/dep/package.lua index e76238d..072495a 100644 --- a/lua/dep/package.lua +++ b/lua/dep/package.lua @@ -1,258 +1,511 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local require, type, setmetatable, error, table, assert, math, os, debug = - require, type, setmetatable, error, table, assert, math, os, debug -local logger = require("dep.log").global +local logger = require('dep.log') +local spec_man = require("dep.spec") +local bench = require("dep.bench") -local function parse_name_from_id(id) - local name = id:match("^[%w-_.]+/([%w-_.]+)$") - if name then - return name - else - error(string.format('invalid package name "%s"; must be in the format "user/package"', id)) +---@class package +---@field id string id of the package +---@field enabled boolean whether it's going to be used +---@field exists boolean if the package exists on the filesystem +---@field lazy boolean if the package is lazy loaded in any way +---@field added boolean if the package has been added in vim +---@field configured boolean if the package has been configured +---@field loaded boolean if a package has been loaded +---@field subtree_loaded boolean is the subtree has been loaded +---@field on_config function[] table of functions to run on config +---@field on_setup function[] table of function to run on setup +---@field on_load function[] table of functions to run on load +---@field lazy_load table table of functions and booleans to run which will tell the package when to load +---@field requirements package[] this package's requirements +---@field dependents package[] packages that require this package +---@field perf table performance metrics for the package +---@field name string the name of the package +---@field url string the url of the package +---@field path string? the path of the package which overrides the url +---@field branch string the branch of the package +---@field dir string the directory of the package +---@field commit string the commit of the package +---@field pin boolean whether to pin the package or not +local package = {} + +--- the base directory for the packages +---@type string +local base_dir + +--- the root package +---@type package +local root + +--- list of all package in dep +---@type package[] +local packages = {} + +--- tell the parent it has a child and the child it has a parent +---@param parent package? parent package if nil defaults to self +---@param child package child package +function package:link_dependency(parent, child) + parent = parent or self + + if not parent.dependents[child.id] then + parent.dependents[child.id] = child + table.insert(parent.dependents, child) + end + + if not child.requirements[parent.id] then + child.requirements[parent.id] = parent + table.insert(child.requirements, parent) end end -local function is_nonempty_str(s) - return type(s) == "string" and #s ~= 0 +--- create a new package instance +---@param spec spec|string a package spec to use for the new package +---@param overrides spec? a package spec that is used to overried this package +---@return package|false package an instance of the package or false on failure +---@nodisacard +function package:new(spec, overrides) + overrides = overrides or {} + + -- ensure that the spec is ok + local new_spec = spec_man.check(spec_man.correct_spec(spec)) + if new_spec == false then + logger:log("spec", vim.inspect(spec)) + logger:log("error", "spec check failed, check DepLog") + return false + else + spec = new_spec + end + + -- start initializing the package + local id = spec[1] + + local o = packages[id] or {} + self.__index = self + setmetatable(o, self) + + -- if package hasn't been registered already, get the inital spec regisitered + if not o.id then + o.id = id + o.enabled = true + o.exists = false + o.lazy = false + o.added = false + o.configured = false + o.loaded = false + o.subtree_loaded = false + o.on_config = {} + o.on_setup = {} + o.on_load = {} + o.lazy_load = {} + + o.requirements = {} + o.dependents = {} + o.perf = {} + + packages[id] = o + end + + o.name = spec.as or o.name or spec_man.get_name(spec) + o.url = spec.url or o.url or ("https://github.com/"..id..".git") + o.path = spec.path and "file://"..vim.fs.normalize(spec.path) or spec.path + o.branch = spec.branch or o.branch + o.dir = base_dir.."/"..o.name + o.commit = spec.commit + o.pin = overrides.pin or spec.pin or o.pin + o.enabled = not overrides.disable and not spec.disable and o.enabled + o.lazy = spec.lazy ~= nil or o.lazy + + -- make sure that the package exists + o.exists = vim.fn.isdirectory(o.dir) ~= 0 + o.configured = o.exists + + -- register all the callback functions + if spec.config ~= nil then + table.insert(o.on_config, spec.config) + end + if spec.setup ~= nil then + table.insert(o.on_setup, spec.setup) + end + if spec.load ~= nil then + table.insert(o.on_load, spec.load) + end + if spec.lazy ~= nil then + table.insert(o.lazy_load, spec.lazy) + end + + -- if the current package isn't the root package then it depends on the root + -- package + if root and o ~= root then + o:link_dependency(root, o) + elseif not root then + root = o + end + + -- link the dependencies + if spec.reqs then + ---it is the correct type as asserted in check_spec() + ---@diagnostic disable-next-line: param-type-mismatch + for _, req in pairs(spec.reqs) do + local pkg = package:new(req) + if type(pkg) == "table" then + o:link_dependency(pkg, o) + end + end + end + + -- and link the dependents + if spec.deps then + ---it is the correct type as asserted in check_spec() + ---@diagnostic disable-next-line: param-type-mismatch + for _, dep in pairs(spec.deps) do + local pkg = package:new(dep) + if not pkg then + return false + end + o:link_dependency(o, pkg) + + -- if the child package is lazy loaded make sure the child package + -- is only loaded when the parent package has finished loading + if o.lazy then + -- tell the dep that it's gonna be lazy + pkg.lazy = true + table.insert(pkg.lazy_load, + require("dep.lazy.loader.short").plugin(id)) + end + end + end + + return o end ---- Package information. -local Package = setmetatable({ - __metatable = "Package", - __index = { - --- Runs all registered hooks of the given type. - run_hooks = function(self, hook) - local hooks = self["on_" .. hook] - if not hooks or #hooks == 0 then - return true - end +--- set the base directory for packages +---@param _base_dir string base directory +function package.set_base_dir(_base_dir) + base_dir = vim.fs.normalize(_base_dir) +end - local start = os.clock() - for i = 1, #hooks do - local ok, err = xpcall(hooks[i], debug.traceback) +--- get the base directory for packages +---@return string base_dir +---@nodiscard +function package.get_base_dir() + return base_dir +end + +--- get the root directory +---@return package root +---@nodiscard +function package.get_root() + return root +end + +--- get the packages in dep +---@return package[] packages +---@nodiscard +function package.get_packages() + return packages +end + +--- run specified hooks on the current package +---@param type "on_load"|"on_config"|"on_setup" which hook to run +---@return boolean, string|nil +function package:runhooks(type) + local hooks = self[type] + if #hooks == 0 then + return true + end + + local start = os.clock() + + -- chdir into the package directory to make running external commands + -- from hooks easier. + local last_cwd = vim.fn.getcwd() + vim.fn.chdir(self.dir) + + for i = 1, #hooks do + local ok, err = pcall(hooks[i]) + if not ok then + vim.fn.chdir(last_cwd) + + return false, err + end + end + + vim.fn.chdir(last_cwd) + self.perf[type] = os.clock() - start + + logger:log("hook", "triggered %d %s %s for %s", #hooks, type, + #hooks == 1 and "hook" or "hooks", self.id) + + return true +end + +--- make sure a package has been loaded +---@param force boolean? force lazy packages to load +---@return boolean|table return true or false if loaded or package spec if lazy loaded +function package:ensureadded(force) + --- load a package + ---@param pkg package + local function loadpkg(pkg) + if not self.enabled then + return false + end + + -- make sure to load the dependencies first + for _, p in pairs(pkg.requirements) do + if not p.loaded then + p:ensureadded(true) + end + end + + -- run setup hooks + pkg:runhooks("on_setup") + + -- now start loading our plugin + local start = os.clock() + + -- trigger the packadd for the plugin + ---@diagnostic disable-next-line: param-type-mismatch + local ok, err = pcall(vim.cmd, "packadd "..pkg.name) + if not ok then + return false, err + end + + pkg.added = true + pkg.perf.pack = os.clock() - start + logger:log("vim", "packadd completed for %s", pkg.id) + + -- set the package to loaded + pkg.loaded = true + logger:log("load", "loaded %s", pkg.id) + + -- trigger the on_load hooks + ok, err = pkg:runhooks("on_load") + if not ok then + logger:log("error", "failed to load %s; reason: %s", pkg.id, err) + return + end + end + + -- make sure the package is lazy loaded if need be + if not self.added and not self.loaded and not self.lazy or force then + loadpkg(self) + elseif not self.added and self.lazy then + logger:log("lazy", "registering %d lazy hooks for %s", #self.lazy_load, + self.id) + for _, load_cond in pairs(self.lazy_load) do + -- configure the lazy loader for the user + local l = require('dep.lazy.loader'):new() + if l == true then + logger:log("lazy", "failed to get lazy utils") + return false + end + l:set_load(function() + -- ensure that we can't attempt to load a plugin twice via lazy loading + if self.loaded then + return + end + + logger:log("lazy", "triggered %d lazy hooks for %s", #self.lazy_load, + self.id) + loadpkg(self) + end) + + -- run it if it's not just a stopper to keep the plugin lazy + if load_cond ~= true then + local ok, err = pcall(load_cond, l) if not ok then - return false, err + logger:log("error", "failed to register lazy load conditions for '%s': %s", + self.name, err) end end + end + return self + end - local elapsed = os.clock() - start - self.perf.hooks[hook] = elapsed + return true +end - logger:log( - "hook", - "triggered %d %s %s for %s in %dms", - #hooks, - hook, - #hooks == 1 and "hook" or "hooks", - self.id, - elapsed - ) +--- load all packages in package tree +---@param force boolean? force lazy packages to load +---@return boolean boolean if tree was successfully loaded +---@nodiscard +function package:loadtree(force) + -- if the package doesn't exist or isn't enabled then don't load it + if not self.exists or not self.enabled then + return false + end - return true - end, - }, -}, { - --- Constructs a new `Package` with the given identifier. - __call = function(mt, id) - local name = parse_name_from_id(id) - return setmetatable({ - id = id, - name = name, - url = "https://github.com/" .. id .. ".git", - enabled = true, - exists = false, - added = false, - configured = false, - loaded = false, - dependencies = {}, - dependents = {}, - subtree_configured = false, - subtree_loaded = false, - on_setup = {}, - on_config = {}, - on_load = {}, - perf = { hooks = {} }, - }, mt) - end, -}) + -- if the subtree is loaded then it's already loaded unless it needs forcing + if not force and self.subtree_loaded then + return true + end ---- Manages a set of packages. -local PackageStore = setmetatable({ - __metatable = "PackageStore", - __index = { - --- Links the given packages such that the parent must load before the child. - link_dependency = function(self, parent, child) - if not parent.dependents[child.id] then - parent.dependents[child.id] = child - parent.dependents[#parent.dependents + 1] = child + -- if the package isn't lazy check that it's requirements are loaded + if not self.lazy then + for _, requirement in pairs(self.requirements) do + if not requirement.loaded and not requirement.lazy then + return false end + end + end - if not child.dependencies[parent.id] then - child.dependencies[parent.id] = parent - child.dependencies[#child.dependencies + 1] = parent - end - end, + -- if the package isn't loaded and isn't lazy then it should probably be + -- loaded + if not self.loaded then + local ok, err = self:ensureadded(force) + if not ok then + logger:log("error", "failed to load %s; reason: %s", self.id, err) + return false + end + end - --- Ensures the given package spec table is valid. - validate_spec = function(self, spec) - assert(spec[1] ~= nil, "package id missing from spec") - assert(type(spec[1]) == "string", "package id must be a string") - parse_name_from_id(spec[1]) + self.subtree_loaded = true - assert(spec.as == nil or is_nonempty_str(spec.as), "package name must be a string") - assert(spec.url == nil or type(spec.url) == "string", "package url must be a string") -- TODO: validate url or path - assert(spec.branch == nil or is_nonempty_str(spec.branch), "package branch must be a string") - assert(spec.pin == nil or type(spec.pin) == "boolean", "package pin must be a boolean") - assert(spec.disable == nil or type(spec.disable) == "boolean", "package disable must be a boolean") + -- make sure the dependants are loaded + for _, dependant in pairs(self.dependents) do + self.subtree_loaded = dependant:loadtree(force) and self.subtree_loaded + end - assert( - spec.requires == nil or type(spec.requires) == "table" or type(spec.requires) == "string", - "package requires must be a string or table" - ) - assert( - spec.deps == nil or type(spec.deps) == "table" or type(spec.deps) == "string", - "package deps must be a string or table" - ) + return self.subtree_loaded +end - assert(spec.setup == nil or type(spec.setup) == "function", "package setup must be a function") - assert(spec.config == nil or type(spec.config) == "function", "package config must be a function") - assert(spec[2] == nil or type(spec[2]) == "function", "package loader must be a function") - end, +--- unconfigure a packages tree +function package:unconfiguretree() + -- unconfigure requirements + for _, requirement in pairs(self.requirements) do + requirement.subtree_loaded = false + end - --- Creates or updates a package from the given spec table, and returns that package. - add_spec = function(self, spec, scope) - self:validate_spec(spec) - scope = scope or {} + -- unconfigure dependents + for _, dependant in pairs(self.dependents) do + dependant.loaded = false + dependant.added = false + dependant.configured = false - local id = spec[1] - local pkg = self[id] + dependant.subtree_loaded = false + end +end - if not pkg then - pkg = Package(id) - self[id], self[#self + 1] = pkg, pkg - end +--- check a list of packages for any cycles +---@param pkgs package[] list of packages +---@return package[]|false cycle the cycle that was found or false if not found +---@nodisacard +function package.findcycle(pkgs) + local index = 0 + local indexes = {} + local lowlink = {} + local stack = {} - -- blend package spec with existing package info - pkg.name = spec.as or pkg.name - pkg.url = spec.url or pkg.url - pkg.branch = spec.branch or pkg.branch - pkg.pin = scope.pin or spec.pin or pkg.pin - pkg.enabled = not scope.disable and not spec.disable and pkg.enabled + --- use tarjan algorithm to find circular dependencies (strongly connected + --- components) + ---@param pkg package + local function connect(pkg) + indexes[pkg.id], lowlink[pkg.id] = index, index + stack[#stack + 1], stack[pkg.id] = pkg, true + index = index + 1 - pkg.on_setup[#pkg.on_setup + 1] = spec.setup - pkg.on_config[#pkg.on_config + 1] = spec.config - pkg.on_load[#pkg.on_load + 1] = spec[2] + for i = 1, #pkg.dependents do + local dependent = pkg.dependents[i] - local requires = type(spec.requires) == "table" and spec.requires or { spec.requires } - local deps = type(spec.deps) == "table" and spec.deps or { spec.deps } - - -- recursively add specs for dependencies and dependents - for i = 1, #requires do - self:link_dependency(self:add_spec(requires[i], scope), pkg) - end - - for i = 1, #deps do - self:link_dependency(pkg, self:add_spec(deps[i], scope)) - end - end, - - --- Adds the given list of specs. - add_specs = function(self, specs, scope) - assert(type(specs) == "table", "package list must be a table") - assert(specs.pin == nil or type(specs.pin) == "boolean", "package list pin must be a boolean") - assert(specs.disable == nil or type(specs.disable) == "boolean", "package list disable must be a boolean") - - scope = scope or {} - scope = { - -- outer scope takes precedence over inner list's overrides - pin = scope.pin or specs.pin, - disable = scope.disable or specs.disable, - } - - -- add specs in spec list - for i = 1, #specs do - self:add_spec(specs[i], scope) - end - end, - - --- Ensures there are no circular dependencies in this package store. - ensure_acyclic = function(self) - -- tarjan's strongly connected components algorithm - local idx, indices, lowlink, stack = 0, {}, {}, {} - - local function connect(pkg) - indices[pkg.id], lowlink[pkg.id] = idx, idx - stack[#stack + 1], stack[pkg.id] = pkg, true - idx = idx + 1 - - for i = 1, #pkg.dependents do - local dependent = pkg.dependents[i] - - if not indices[dependent.id] then - local cycle = connect(dependent) - if cycle then - return cycle - else - lowlink[pkg.id] = math.min(lowlink[pkg.id], lowlink[dependent.id]) - end - elseif stack[dependent.id] then - lowlink[pkg.id] = math.min(lowlink[pkg.id], indices[dependent.id]) - end - end - - if lowlink[pkg.id] == indices[pkg.id] then - local cycle = { pkg } - local node - - repeat - node = stack[#stack] - stack[#stack], stack[node.id] = nil, nil - cycle[#cycle + 1] = node - until node == pkg - - -- a node is by definition strongly connected to itself - -- ignore single-node components unless the package explicitly specified itself as a dependency (i.e. the user is being weird) - if #cycle > 2 or pkg.dependents[pkg.id] then - return cycle - end + if not indexes[dependent.id] then + local cycle = connect(dependent) + if cycle then + return cycle + else + lowlink[pkg.id] = math.min(lowlink[pkg.id], lowlink[dependent.id]) end + elseif stack[dependent.id] then + lowlink[pkg.id] = math.min(lowlink[pkg.id], indexes[dependent.id]) end + end - for i = 1, #self do - local pkg = self[i] + if lowlink[pkg.id] == indexes[pkg.id] then + local cycle = { pkg } + local node - if not indices[pkg.id] then - local cycle = connect(pkg) - if cycle then - -- found dependency cycle - local names = {} - for j = 1, #cycle do - names[j] = cycle[j].id - end - error("circular dependency detected in package dependency graph: " .. table.concat(names, " -> ")) - end - end + repeat + node = stack[#stack] + stack[#stack], stack[node.id] = nil, nil + cycle[#cycle + 1] = node + until node == pkg + + -- a node is by definition strongly connected to itself ignore single-node + -- components unless it explicitly specified itself as a dependency + if #cycle > 2 or pkg.dependents[pkg.id] then + return cycle end - end, - }, -}, { - --- Constructs a new `PackageStore`. - __call = function(mt) - -- hash part of store maps package ids to packages - -- array part of store is a list of packages - -- all packages in a store are unique based on their id - return setmetatable({}, mt) - end, -}) + end + end -return { - Package = Package, - PackageStore = PackageStore, -} + -- actually check the cycle + for _, pkg in pairs(pkgs) do + if not indexes[package.id] then + local cycle = connect(pkg) + if cycle then + return cycle + end + end + end + + return false +end + +--- recurse over all packages and register them +---@param speclist speclist table of specs +---@param overrides spec? a package spec that is used to override options +---@return package[] packages +function package.register_speclist(speclist, overrides) + overrides = overrides or {} + local packages_from_speclist = {} + + -- recurse the packages + local over = overrides + for _, spec in ipairs(speclist) do + -- make sure the overrides override and take into account the packages spec + ---@diagnostic disable-next-line: missing-fields + over = { + pin = overrides.pin or spec.pin, + disable = overrides.disable or spec.disable + } + + -- While a package can fail to load we just don't care, it will work itself + -- out. The goal is to make sure every plugin that can load does load, not + -- keep working plugins from loading because an unrelated one doesn't load. + local pkg = package:new(spec, over) + if not pkg then + goto continue + end + + -- we store all the packages in a table so that the caller may keep track of + -- their packages, this is not required and therefore the return value may + -- be discarded + table.insert(packages_from_speclist, pkg) + ::continue:: + end + + return packages_from_speclist +end + +--- reload the package +---@param self package the package to reload +---@param force boolean? force all packages to load +function package:reload(force) + local reloaded = self:loadtree(force) + + if reloaded then + local ok, err + -- TODO: make a benchmark function + bench.mark("reload", function() + ok, err = pcall(vim.cmd, + [[ + silent! helptags ALL + silent! UpdateRemotePlugins + ]]) + end) + + if not ok then + logger:log("error", + "failed to reload helptags and remote plugins; reason: %s", err) + end + end +end + +return package diff --git a/lua/dep/proc.lua b/lua/dep/proc.lua deleted file mode 100644 index cf3aa9c..0000000 --- a/lua/dep/proc.lua +++ /dev/null @@ -1,74 +0,0 @@ -local logger = require("dep.log").global -local proc = {} - -function proc.exec(process, args, cwd, env, cb) - local buffer = {} - - local function cb_output(_, data, _) - table.insert(buffer, table.concat(data)) - end - local function cb_exit(job_id, exit_code, _) - local output = table.concat(buffer) - logger:log( - process, - string.format( - 'Job %s ["%s"] finished with exitcode %s\n%s', - job_id, - table.concat(args, '", "'), - exit_code, - output) - ) - cb(exit_code ~= 0, output) - end - table.insert(args, 1, process) - vim.fn.jobstart(args, { - cwd = cwd, - env = env, - stdin = nil, - on_exit = cb_exit, - on_stdout = cb_output, - on_stderr = cb_output, - }) -end - -local git_env = { GIT_TERMINAL_PROMPT = 0 } - -function proc.git_rev_parse(dir, arg, cb) - local args = { "rev-parse", "--short", arg } - - proc.exec("git", args, dir, git_env, cb) -end - -function proc.git_clone(dir, url, branch, cb) - local args = { "clone", "--depth=1", "--recurse-submodules", "--shallow-submodules", url, dir } - - if branch then - args[#args + 1] = "--branch=" .. branch - end - - proc.exec("git", args, nil, git_env, cb) -end - -function proc.git_fetch(dir, remote, refspec, cb) - local args = { "fetch", "--depth=1", "--recurse-submodules", remote, refspec } - - proc.exec("git", args, dir, git_env, cb) -end - -function proc.git_reset(dir, treeish, cb) - local args = { "reset", "--hard", "--recurse-submodules", treeish, "--" } - - proc.exec("git", args, dir, git_env, cb) -end - -function proc.git_checkout(dir, branch, commit, cb) - local args = { "fetch", "--depth=2147483647", "origin", branch } - proc.exec("git", args, dir, git_env, function(err, message) - cb(err, message) - - args = { "checkout", commit } - proc.exec("git", args, dir, git_env, cb) - end) -end - -return proc diff --git a/lua/dep/spec.lua b/lua/dep/spec.lua new file mode 100644 index 0000000..e7b1d9d --- /dev/null +++ b/lua/dep/spec.lua @@ -0,0 +1,212 @@ +local logger = require("dep.log") + +---@class specmodules +---@field prefix string prefix to prepend to the modules +---@field [integer] string list of all modules to load + +---@class speclist +---@field modules specmodules? a list of modules +---@field base_dir string? the base directory for all plugins +---@field sync ("new"|"always")? when to sync (defaults to new) +---@field [integer] spec a spec + +---@class spec +---@field [1] string id +---@field setup function? code to run before the package is loaded +---@field load function? code to run after the package is loaded +---@field config function? code to run after the package is installed/updated +---@field lazy function|true? code to run which determines when the package is loaded +---@field as string? overrides the short name of the package which is usually set +--- to a substring of all the chars after "/" in spec[1] +---@field url string? the url to the git repository hosting the package +---@field path string? path to local version of plugin, overrides the url +---@field branch string? the branch which the version of the package resides +---@field commit string? the commit which the version of the package resides +---@field disable boolean? if true disables the package from being loaded +---@field pin boolean? if true disables the package from being installed/updated +---@field reqs spec|spec[]|string? packages that this package requires +---@field deps spec|spec[]|string? packages that depend on this package +local spec = {} + +--- check if a string seems to be a url +---@param url string the "url" to check +---@return boolean is_url +local function is_url(url) + if url:sub(1, 8) == "https://" or + url:sub(1, 7) == "http://" then + return true + end + return false +end + +--- get the proper name of a spec +---@return string spec.name +function spec:get_name() + return self[1]:match("^[%w-_.]+/([%w-_.]+)$") +end + +--- attempt to correct a spec +---@param self table|string spec to check +---@return spec spec +function spec:correct_spec() + if type(self) == "string" then + return { self } + elseif type(self) == "table" then + repeat + if type(self[1]) ~= "string" then + self = self[1] + elseif self[1] == nil then + break + end + until type(self[1]) == "string" + end + + return self +end + +-- store the logger temporarily to prevent any logs from being printed when +-- being run in silent mode +local __logger + +--- check a spec to see if it's correct +---@param self table spec to check +---@param silent boolean? should the checker report errors +---@return spec|false spec if the spec is ok or false +function spec:check(silent) + if silent == true then + __logger = logger + logger = { log = function() end } + end + + -- make sure all the data is correct + do -- spec[1] + if type(self[1]) ~= "string" then + logger:log("spec", "spec[1] must be a string") + return false + end + + local name = spec.get_name(self) + if not name then + logger:log("spec", 'invalid name "%s"; must be in the format "user/package"', self[1]) + return false + end + end + + if self.setup ~= nil then -- spec.setup + if type(self.setup) ~= "function" then + logger:log("spec", "spec.setup must be a function in %s", self[1]) + return false + end + end + + if self.load ~= nil then -- spec.load + if type(self.load) ~= "function" then + logger:log("spec", "spec.load must be a function in %s", self[1]) + return false + end + end + + if self.config ~= nil then -- spec.config + if type(self.config) ~= "function" then + logger:log("spec", "spec.config must be a function in %s", self[1]) + return false + end + end + + if self.lazy ~= nil then -- spec.lazy + if type(self.lazy) ~= "function" and self.lazy ~= true then + logger:log("spec", "spec.lazy must be a function or boolean in %s", + self[1]) + return false + end + end + + if self.as ~= nil then -- spec.as + if type(self.as) ~= "string" then + logger:log("spec", "spec.as must be a string in %s", self[1]) + return false + end + end + + if self.url ~= nil then -- spec.url + if type(self.url) ~= "string" then + logger:log("spec", "spec.url must be a string in %s", self[1]) + return false + elseif not is_url(self.url) then -- more strict checking on urls + logger:log("spec", "spec.url must be a properly formatted url in %s", + self[1]) + return false + end + end + + if self.path ~= nil then -- spec.path + if type(self.path) ~= "string" then + logger:log("spec", "spec.path must be a string in %s", self[1]) + return false + elseif not vim.fn.isdirectory(self.path) then + logger:log("spec", "spec.path must be a valid directory in %s", self[1]) + return false + end + end + + if self.branch ~= nil then -- spec.branch + if type(self.branch) ~= "string" then + logger:log("spec", "spec.branch must be a string in %s", self[1]) + return false + end + end + + if self.commit ~= nil then -- spec.commit + if type(self.commit) ~= "string" then + logger:log("spec", "spec.commit must be a string in %s", self[1]) + return false + end + end + + if self.disable ~= nil then -- spec.disable + if type(self.disable) ~= "boolean" then + logger:log("spec", "spec.disable must be a boolean in %s", self[1]) + return false + end + end + + if self.pin ~= nil then -- spec.pin + if type(self.pin) ~= "boolean" then + logger:log("spec", "spec.pin must be a boolean in %s", self[1]) + return false + end + end + + if self.reqs ~= nil then -- spec.reqs + local is = type(self.reqs) + if is ~= "table" and is ~= "string" then + logger:log("spec", "spec.reqs must be a table or a string in %s", self[1]) + return false + end + + -- turn an id into a spec + if (is == "string") then + self.reqs = { self.reqs } + end + end + + if self.deps ~= nil then -- spec.deps + local is = type(self.deps) + if is ~= "table" and is ~= "string" then + logger:log("spec", "spec.deps must be a table or a string in %s", self[1]) + return false + end + + -- turn an id into a spec + if (is == "string") then + self.deps = { self.deps } + end + end + + if silent == true then + logger = __logger + end + return self +end + +return spec diff --git a/lua/dep2.lua b/lua/dep2.lua deleted file mode 100644 index a4d4953..0000000 --- a/lua/dep2.lua +++ /dev/null @@ -1,12 +0,0 @@ --- --- Copyright (c) 2022 chiya.dev --- --- Use of this source code is governed by the MIT License --- which can be found in the LICENSE file and at: --- --- https://chiya.dev/licenses/mit.txt --- -local logger = require("dep.log").global -local store = require("dep.package").PackageStore() - --- placeholder for refactoring diff --git a/stylua.toml b/stylua.toml deleted file mode 100755 index 0435f67..0000000 --- a/stylua.toml +++ /dev/null @@ -1,2 +0,0 @@ -indent_type = "Spaces" -indent_width = 2 diff --git a/tests/dep_spec.lua b/tests/dep_spec.lua new file mode 100644 index 0000000..23ee2a0 --- /dev/null +++ b/tests/dep_spec.lua @@ -0,0 +1,36 @@ +---@diagnostic disable: undefined-global, undefined-field +local dep_spec_man = require("dep.spec") + +describe("package specification", function() + it("gets the package's name", function() + assert.equal(dep_spec_man.get_name({ "user/package" }), "package") + assert.equal(dep_spec_man.get_name({ "user/package.git" }), "package.git") + end) + + it("ensurses specs are in the proper format", function() + local correct = { "user/package" } + assert.same(dep_spec_man.correct_spec("user/package"), correct) + assert.same(dep_spec_man.correct_spec({ "user/package" }), correct) + assert.same(dep_spec_man.correct_spec({ { "user/package" } }), correct) + end) + + it("checks a spec for correctness", function() + assert.same( + dep_spec_man.check({ "user/package" }, true), + { "user/package" } + ) + + assert.same( + dep_spec_man.check({ + "user/package", + deps = "user/dependency" + }, true), + { + "user/package", + deps = { + "user/dependency" + } + } + ) + end) +end) diff --git a/tests/minit.lua b/tests/minit.lua new file mode 100644 index 0000000..22d1049 --- /dev/null +++ b/tests/minit.lua @@ -0,0 +1 @@ +vim.opt.rtp:prepend(".")