8000 perf: speed up startup with many plugins by zuqini · Pull Request #50 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ parent's lazy trigger fires.
cond = true|false|function(plugin), -- Condition to load
lazy = true|false, -- Force eager (auto-detect)
priority = 50, -- Load order (default: 50)
-- doesn't order different ft patterns

-- Plugin configuration
opts = {}, -- Options passed to setup()
Expand All @@ -634,6 +635,9 @@ parent's lazy trigger fires.
cmd = string|string[]|fn(plugin), -- Command(s) to create
keys = KeySpec|KeySpec[]|fn(plugin), -- Keymap(s) to create
ft = string|string[]|function(plugin), -- FileType(s) to lazy load on
-- plugins sharing a filetype load
-- together and re-trigger FileType
-- once, after the last of them

-- Source control (version for `vim.pack.add`, string|vim.VersionRange)
version = "main", -- Branch/tag/commit
Expand Down Expand Up @@ -746,8 +750,10 @@ priority (number, optional)
Load priority for both startup and lazy-loaded plugins.
Higher values load earlier. Default: 50. For lazy plugins
with the same trigger (e.g., same event), higher priority
plugins load first. Useful for colorschemes and plugins
that other plugins depend on.
plugins load first. Priority doesn't order different `ft`
patterns, such as a literal and a glob: each loads when
its own pattern's turn comes. Useful for colorschemes and
plugins that other plugins depend on.

*zpack-Spec.opts*
opts (table|function(plugin, opts), optional)
Expand Down Expand Up @@ -885,7 +891,17 @@ ft (string|string[]|function(plugin), optional)
|zpack.Plugin| that returns the value. Automatically
re-triggers BufReadPre, BufReadPost, and FileType events
after loading to ensure LSP clients and Treesitter attach
properly. Auto-sets `lazy=true`.
properly. Plugins sharing a filetype load together in
priority order and re-trigger once, after the last of
them, so a plugin's `config` doesn't see `ftplugin` or
BufRead handler effects from others in the group, even with
`dependencies` declared. Read such values from a FileType
handler or the plugin's own `ftplugin` instead. A plugin
with `event = 'FileType'` between them in priority order
splits the group if its pattern can match that filetype. A
value with no filetypes warns and the plugin never loads
from it, and an invalid pattern warns and is skipped.
Auto-sets `lazy=true`.

*zpack-Spec.version*
version (string|vim.VersionRange, optional)
Expand Down
8 changes: 8 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ return {
}
```

Plugins sharing a filetype load together in priority order and re-trigger these events once, after the last of them:

- A plugin's `config` doesn't see `ftplugin` or `BufReadPre`/`BufReadPost` handler effects from others in the group, even with `dependencies` declared. Read such values from a `FileType` handler or the plugin's own `ftplugin` instead.
- If several plugins ship an `ftplugin` guarded by `b:did_ftplugin`, the lowest-priority plugin wins.
- If a plugin's `config` loads another buffer of that filetype, `FileType` handlers of plugins still pending in the group miss that buffer. Declaring the pending plugin as a dependency avoids this.
- Priority doesn't order different `ft` patterns, such as a literal and a glob: each loads when its own pattern's turn comes.
- A plugin with `event = 'FileType'` between them in priority order splits the group if its pattern can match that filetype, at the cost of one extra re-trigger.

#### Conditional Loading

Use `enabled` to skip `vim.pack.add` entirely, or `cond` to conditionally load after calling `vim.pack.add`:
Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
enabled = true|false|function, -- Enable/disable plugin
cond = true|false|function(plugin), -- Condition to load plugin
lazy = true|false, -- Force eager loading when false (auto-detected)
priority = 50, -- Load priority (higher = earlier, default: 50)
priority = 50, -- Load priority (higher = earlier, default: 50). Doesn't order different `ft` patterns.

-- Plugin configuration
opts = {}, -- Options passed to setup(), triggers auto-setup
Expand All @@ -48,7 +48,7 @@
pattern = string|string[], -- Global fallback pattern(s) for all events
cmd = string|string[]|function(plugin), -- Command(s) to create; tab-completion on the command also triggers the load
keys = zpack.KeySpec|zpack.KeySpec[]|function(plugin), -- Keymap(s) to create
ft = string|string[]|function(plugin), -- FileType(s) to lazy load on
ft = string|string[]|function(plugin), -- FileType(s) to lazy load on. Plugins sharing a filetype load together and re-trigger FileType once, after the last of them.

-- Source control (version for `vim.pack.add`, string|vim.VersionRange)
version = "main", -- Git branch, tag, or commit
Expand Down
4 changes: 2 additions & 2 deletions lua/zpack/hooks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ M.setup_lazy_build_tracking = function()
end, { group = state.lazy_build_group })
end

M.run_pending_builds_on_startup = function(ctx)
M.run_pending_builds_on_startup = function()
if next(state.src_with_pending_build) == nil then
return
end
Expand All @@ -197,7 +197,7 @@ M.run_pending_builds_on_startup = function(ctx)
local spec = entry and entry.merged_spec
if spec and spec.build then
local pack_spec = state.src_to_pack_spec[src]
local load_ok = pack_spec == nil or loader.try_process_spec(pack_spec, { bang = not ctx.load })
local load_ok = pack_spec == nil or loader.try_process_spec(pack_spec, { bang = true })
if load_ok then
M.execute_build(spec.build, entry.plugin, src)
end
Expand Down
4 changes: 1 addition & 3 deletions lua/zpack/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ local M = {}
---@field src_with_init string[]
---@field registered_startup_packs vim.pack.Spec[]
---@field registered_lazy_packs vim.pack.Spec[]
---@field load boolean?
---@field confirm boolean?
---@field defaults zpack.Config.Defaults
---@field is_dependency? boolean Internal: Whether currently importing as dependency
Expand All @@ -23,7 +22,6 @@ local function create_context(opts)
src_with_init = {},
registered_startup_packs = {},
registered_lazy_packs = {},
load = opts.load,
confirm = opts.confirm,
defaults = opts.defaults or {},
}
Expand Down Expand Up @@ -94,7 +92,7 @@ local process_all = function(ctx)

require('zpack.startup').process_all(ctx)
require('zpack.lazy').process_all(ctx)
hooks.run_pending_builds_on_startup(ctx)
hooks.run_pending_builds_on_startup()
vim.api.nvim_clear_autocmds({ group = state.startup_group })
hooks.setup_lazy_build_tracking()
end
Expand Down
15 changes: 15 additions & 0 deletions lua/zpack/lazy_trigger/event.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ local util = require('zpack.utils')
local state = require('zpack.state')
local loader = require('zpack.plugin_loader')
local refire = require('zpack.lazy_trigger.refire')
local ft_trigger = require('zpack.lazy_trigger.ft')

local M = {}

Expand Down Expand Up @@ -65,6 +66,17 @@ local split_very_lazy = function(events)
return has_very_lazy, other_events
end

---@param events string[]
---@return boolean
local includes_filetype = function(events)
for _, event in ipairs(events) do
if event:lower() == "filetype" then
return true
end
end
return false
end

---Schedule `cb` on the next tick (if vim has entered) or on the next
---UIEnter (latched against nvim#25526). Shared by the per-plugin VeryLazy
---load and the `User VeryLazy` emit so the latch lives in one place.
Expand Down Expand Up @@ -115,6 +127,9 @@ M.setup = function(pack_spec, spec, event)
end

if #other_events > 0 then
if includes_filetype(other_events) then
ft_trigger.close_groups(normalized_event.pattern)
end
-- latch_first_call gates before the load_status check so a second
-- nested fire in the same tick bails before refire.exec can double-fire
-- user autocmds. The load_status gate handles other races (sibling
Expand Down
150 changes: 135 additions & 15 deletions lua/zpack/lazy_trigger/ft.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,126 @@ local refire = require('zpack.lazy_trigger.refire')

local M = {}

---Plugins sharing a filetype load together and re-fire once: the re-fire runs
---every FileType handler, including the ftplugin, indent and syntax :runtime!
---scans over all of 'runtimepath', so one re-fire per plugin scales with both.
---Each group has its own autocmd; this holds the one still accepting plugins.
---@type { [string]: (fun(): boolean?)[] }
local open_groups = {}

---@param filetype string
---@return (fun(): boolean?)[]? group nil when the autocmd rejects `filetype` as a pattern
local function open_group(filetype)
local group = {}
-- The latch guards against nvim#25526: the plugin's own `ftplugin/*` sourced
-- during packadd can nest-fire FileType on the same buffer despite `once`.
local ok = pcall(util.autocmd, "FileType", util.latch_first_call(function(ev)
if open_groups[filetype] == group then
open_groups[filetype] = nil
end

local snap = refire.snapshot("FileType")
local any_loaded = false
for _, try_load in ipairs(group) do
any_loaded = try_load() or any_loaded
end
if any_loaded then
refire.exec(ev, snap)
end
end), { group = state.lazy_group, pattern = filetype, })
if not ok then
return nil
end
open_groups[filetype] = group
return group
end

---Autocmd patterns are comma-separated, except inside `{}` or after a backslash.
---@param pattern string
---@return string[] patterns
local function split_patterns(pattern)
if pattern:find('[{\\]') then
return { pattern }
end
local patterns = {}
for part in pattern:gmatch('[^,]+') do
table.insert(patterns, part)
end
return patterns
end

---@param pattern string
---@return boolean
local function is_literal(pattern)
return pattern:match('^[%w_%-%.]+$') ~= nil
end

---Later plugins start new groups, so they run after an autocmd registered now.
---An `event = 'FileType'` plugin keeps its priority slot between ft plugins, so
---this closes the groups `patterns` can fire alongside.
---@param patterns string|string[]
M.close_groups = function(patterns)
local entries = util.normalize_string_list(patterns)
-- v0.12.0 treats an empty pattern list as matching everything
if #entries == 0 then
open_groups = {}
return
end
-- Lowercased because 'fileignorecase' makes spellings of a name match
local literals = {}
for _, entry in ipairs(entries) do
for _, pattern in ipairs(split_patterns(entry)) do
if not is_literal(pattern) then
open_groups = {}
return
end
literals[pattern:lower()] = true
end
end
for filetype in pairs(open_groups) do
if literals[filetype:lower()] or not is_literal(filetype) then
open_groups[filetype] = nil
end
end
end

---@param ft any
---@return string[] filetypes
---@return boolean ignored whether `ft` held anything besides a list of filetypes
local function usable_filetypes(ft)
local list = type(ft) == "string" and { ft } or ft
if type(list) ~= "table" then
return {}, true
end
local filetypes = {}
local usable = 0
for _, entry in ipairs(list) do
if type(entry) == "string" and entry ~= "" then
usable = usable + 1
vim.list_extend(filetypes, split_patterns(entry))
end
end
return filetypes, usable < vim.tbl_count(list)
end

---@param pack_spec vim.pack.Spec
---@param ft zpack.FtValue
M.setup = function(pack_spec, ft)
local filetypes = util.normalize_string_list(ft)
local filetypes, ignored = usable_filetypes(ft)
local label = pack_spec.name or pack_spec.src
if #filetypes == 0 then
util.schedule_notify(
("%s: ft has no filetypes, so no filetype will load it"):format(label),
vim.log.levels.WARN
)
return
end
if ignored then
util.schedule_notify(
("%s: ignoring ft entries other than a list of non-empty strings"):format(label),
vim.log.levels.WARN
)
end

-- Source the plugin's ftdetect/* now so its filetype rules are active
-- before any file is opened. Without this, `ft = '<custom>'` for a plugin
Expand All @@ -20,22 +136,26 @@ M.setup = function(pack_spec, ft)
util.source_ftdetect_files(plugin_path)
end

-- latch_first_call guards against nvim#25526; needed here because the
-- plugin's own `ftplugin/*` sourced during packadd can nest-fire FileType
-- on the same buffer before load_status flips.
util.autocmd("FileType", util.latch_first_call(function(ev)
-- Skip when a sibling already loaded (avoid double-fire) or is
-- mid-load (avoid spurious circular-dependency notify).
-- Latched per plugin, not per filetype: a plugin that failed on one filetype
-- must not retry on the next, since :packadd re-sources plugin/ every time.
local try_load = util.latch_first_call(function()
-- Skip when a sibling already loaded (avoid double-fire), is mid-load
-- (avoid spurious circular-dependency notify), or was removed.
local entry = state.spec_registry[pack_spec.src]
if entry and entry.load_status ~= "pending" then
return
end
local snap = refire.snapshot("FileType")
if not loader.try_process_spec(pack_spec) then
return
return entry and entry.load_status == "pending" and loader.try_process_spec(pack_spec)
end)

for _, filetype in ipairs(filetypes) do
local group = open_groups[filetype] or open_group(filetype)
if group then
table.insert(group, try_load)
else
util.schedule_notify(
("%s: ignoring ft '%s', which isn't a valid autocmd pattern"):format(label, filetype),
vim.log.levels.WARN
)
end
refire.exec(ev, snap)
end), { group = state.lazy_group, pattern = filetypes, })
end
end

return M
39 changes: 27 additions & 12 deletions lua/zpack/startup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ local function toposort_startup_packs(packs)
return result, lazy_deps_map
end

---Add every pack to 'runtimepath' in one write. A pack the batch can't verify
---leaves the whole list to `:packadd!`, whose order in 'runtimepath' follows the
---load order, and which reports the plugin an init hook removed.
---@param packs vim.pack.Spec[]
---@return vim.pack.Spec[] remaining packs that still need `:packadd`
local function add_installed_to_rtp(packs)
local paths = {}
for i, pack_spec in ipairs(packs) do
local entry = state.spec_registry[pack_spec.src]
local path = entry and entry.plugin and entry.plugin.path
local stat = path and vim.uv.fs_stat(path)
if not stat or stat.type ~= 'directory' then
return packs
end
paths[i] = path
end
return util.add_packs_to_rtp(paths) and {} or packs
end

---@param ctx zpack.ProcessContext
M.process_all = function(ctx)
table.sort(ctx.src_with_init, util.compare_priority)
Expand All @@ -79,21 +98,17 @@ M.process_all = function(ctx)

local sorted_packs, lazy_deps_map = toposort_startup_packs(ctx.registered_startup_packs)

-- pcall packadd per plugin so one broken plugin doesn't strand every
-- later one. Track failures so later loops (run_config, apply_keys,
-- finalization) skip them — otherwise the failed plugin would be marked
-- loaded and hidden from :ZPack load / :checkhealth.
-- Batched plugins are already on 'runtimepath'. pcall packadd for the rest
-- so one broken plugin doesn't strand every later one. Track failures so
-- later loops (run_config, apply_keys, finalization) skip them — otherwise
-- the failed plugin would be marked loaded and hidden from :ZPack load /
-- :checkhealth.
local failed_packs = {}
for _, pack_spec in ipairs(sorted_packs) do
local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = not ctx.load })
for _, pack_spec in ipairs(add_installed_to_rtp(sorted_packs)) do
local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = true })
if not ok then
failed_packs[pack_spec.src] = true
util.schedule_notify(("Failed to packadd %s: %s"):format(pack_spec.name or pack_spec.src, tostring(err)), vim.log.levels.ERROR)
elseif ctx.load then
local entry = state.spec_registry[pack_spec.src]
if entry and entry.plugin and entry.plugin.path then
util.source_after_plugin_files(entry.plugin.path)
end
end
end

Expand All @@ -104,7 +119,7 @@ M.process_all = function(ctx)
for _, dep_src in ipairs(lazy_deps) do
local dep_pack = state.src_to_pack_spec[dep_src]
if dep_pack then
loader.try_process_spec(dep_pack, { bang = not ctx.load })
loader.try_process_spec(dep_pack, { bang = true })
end
end
end
Expand Down
Loading
Loading
0