loader.lua (15202B)
1 local fs = vim.fs -- "vim.fs" is a dependency, so must be loaded early. 2 local uv = vim.uv 3 local uri_encode = vim.uri_encode --- @type function 4 5 --- @type (fun(modename: string): fun()|string)[] 6 local loaders = package.loaders 7 local _loadfile = loadfile 8 9 local VERSION = 4 10 local is_appimage = (os.getenv('APPIMAGE') ~= nil) 11 12 local M = {} 13 14 --- @alias vim.loader.CacheHash {mtime: {nsec: integer, sec: integer}, size: integer, type?: string} 15 --- @alias vim.loader.CacheEntry {hash:vim.loader.CacheHash, chunk:string} 16 17 --- @class vim.loader.find.Opts 18 --- @inlinedoc 19 --- 20 --- Search for modname in the runtime path. 21 --- (default: `true`) 22 --- @field rtp? boolean 23 --- 24 --- Extra paths to search for modname 25 --- (default: `{}`) 26 --- @field paths? string[] 27 --- 28 --- List of patterns to use when searching for modules. 29 --- A pattern is a string added to the basename of the Lua module being searched. 30 --- (default: `{"/init.lua", ".lua"}`) 31 --- @field patterns? string[] 32 --- 33 --- Search for all matches. 34 --- (default: `false`) 35 --- @field all? boolean 36 37 --- @class vim.loader.ModuleInfo 38 --- @inlinedoc 39 --- 40 --- Path of the module 41 --- @field modpath string 42 --- 43 --- Name of the module 44 --- @field modname string 45 --- 46 --- The fs_stat of the module path. Won't be returned for `modname="*"` 47 --- @field stat? uv.fs_stat.result 48 49 --- @alias vim.loader.Stats table<string, {total:number, time:number, [string]:number?}?> 50 51 --- @private 52 M.path = vim.fn.stdpath('cache') .. '/luac' 53 54 --- @private 55 M.enabled = false 56 57 --- @type vim.loader.Stats 58 local stats = { find = { total = 0, time = 0, not_found = 0 } } 59 60 --- @type table<string, uv.fs_stat.result>? 61 local fs_stat_cache 62 63 --- @type table<string, table<string,vim.loader.ModuleInfo>> 64 local indexed = {} 65 66 --- @param path string 67 --- @return uv.fs_stat.result? 68 local function fs_stat_cached(path) 69 if not fs_stat_cache then 70 return (uv.fs_stat(path)) 71 end 72 73 if not fs_stat_cache[path] then 74 -- Note we must never save a stat for a non-existent path. 75 -- For non-existent paths fs_stat() will return nil. 76 fs_stat_cache[path] = uv.fs_stat(path) 77 end 78 return fs_stat_cache[path] 79 end 80 81 local function normalize(path) 82 return fs.normalize(path, { expand_env = false, _fast = true }) 83 end 84 85 local rtp_cached = {} --- @type string[] 86 local rtp_cache_key --- @type string? 87 88 --- Gets the rtp excluding after directories. 89 --- The result is cached, and will be updated if the runtime path changes. 90 --- When called from a fast event, the cached value will be returned. 91 --- @return string[] rtp, boolean updated 92 local function get_rtp() 93 if vim.in_fast_event() then 94 return (rtp_cached or {}), false 95 end 96 local updated = false 97 local key = vim.go.rtp 98 if key ~= rtp_cache_key then 99 rtp_cached = {} 100 for _, path in ipairs(vim.api.nvim_get_runtime_file('', true)) do 101 path = normalize(path) 102 -- skip after directories 103 if 104 path:sub(-6, -1) ~= '/after' 105 and not (indexed[path] and vim.tbl_isempty(indexed[path])) 106 then 107 rtp_cached[#rtp_cached + 1] = path 108 end 109 end 110 updated = true 111 rtp_cache_key = key 112 end 113 return rtp_cached, updated 114 end 115 116 --- Returns the cache file name 117 --- @param name string can be a module name, or a file name 118 --- @return string file_name 119 local function cache_filename(name) 120 if is_appimage then 121 -- Avoid cache pollution caused by AppImage randomizing the program root. #31165 122 -- "/tmp/.mount_nvimAmpHPH/usr/share/nvim/runtime" => "/usr/share/nvim/runtime" 123 name = name:match('(/usr/.*)') or name 124 end 125 126 local ret = ('%s/%s'):format(M.path, uri_encode(name, 'rfc2396')) 127 return ret:sub(-4) == '.lua' and (ret .. 'c') or (ret .. '.luac') 128 end 129 130 --- Saves the cache entry for a given module or file 131 --- @param cname string cache filename 132 --- @param hash vim.loader.CacheHash 133 --- @param chunk function 134 local function write_cachefile(cname, hash, chunk) 135 local f = assert(uv.fs_open(cname, 'w', 438)) 136 local header = { 137 VERSION, 138 hash.size, 139 hash.mtime.sec, 140 hash.mtime.nsec, 141 } 142 uv.fs_write(f, table.concat(header, ',') .. '\0') 143 uv.fs_write(f, string.dump(chunk)) 144 uv.fs_close(f) 145 end 146 147 --- @param path string 148 --- @param mode integer 149 --- @return string? data 150 local function readfile(path, mode) 151 local f = uv.fs_open(path, 'r', mode) 152 if f then 153 local size = assert(uv.fs_fstat(f)).size 154 local data = uv.fs_read(f, size, 0) 155 uv.fs_close(f) 156 return data 157 end 158 end 159 160 --- Loads the cache entry for a given module or file 161 --- @param cname string cache filename 162 --- @return vim.loader.CacheHash? hash 163 --- @return string? chunk 164 local function read_cachefile(cname) 165 local data = readfile(cname, 438) 166 if not data then 167 return 168 end 169 170 local zero = data:find('\0', 1, true) 171 if not zero then 172 return 173 end 174 175 --- @type integer[]|{[0]:integer} 176 local header = vim.split(data:sub(1, zero - 1), ',') 177 if tonumber(header[1]) ~= VERSION then 178 return 179 end 180 181 local hash = { 182 size = tonumber(header[2]), 183 mtime = { sec = tonumber(header[3]), nsec = tonumber(header[4]) }, 184 } 185 186 local chunk = data:sub(zero + 1) 187 188 return hash, chunk 189 end 190 191 --- The `package.loaders` loader for Lua files using the cache. 192 --- @param modname string module name 193 --- @return string|function 194 local function loader_cached(modname) 195 fs_stat_cache = {} 196 local ret = M.find(modname)[1] 197 if ret then 198 -- Make sure to call the global loadfile so we respect any augmentations done elsewhere. 199 -- E.g. profiling 200 local chunk, err = loadfile(ret.modpath) 201 fs_stat_cache = nil 202 return chunk or error(err) 203 end 204 fs_stat_cache = nil 205 return ("\n\tcache_loader: module '%s' not found"):format(modname) 206 end 207 208 local is_win = vim.fn.has('win32') == 1 209 210 --- The `package.loaders` loader for libs 211 --- @param modname string module name 212 --- @return string|function 213 local function loader_lib_cached(modname) 214 local ret = M.find(modname, { patterns = { is_win and '.dll' or '.so' } })[1] 215 if not ret then 216 return ("\n\tcache_loader_lib: module '%s' not found"):format(modname) 217 end 218 219 -- Making function name in Lua 5.1 (see src/loadlib.c:mkfuncname) is 220 -- a) strip prefix up to and including the first dash, if any 221 -- b) replace all dots by underscores 222 -- c) prepend "luaopen_" 223 -- So "foo-bar.baz" should result in "luaopen_bar_baz" 224 local dash = modname:find('-', 1, true) 225 local funcname = dash and modname:sub(dash + 1) or modname 226 local chunk, err = package.loadlib(ret.modpath, 'luaopen_' .. funcname:gsub('%.', '_')) 227 return chunk or error(err) 228 end 229 230 --- Checks whether two cache hashes are the same based on: 231 --- * file size 232 --- * mtime in seconds 233 --- * mtime in nanoseconds 234 --- @param a? vim.loader.CacheHash 235 --- @param b? vim.loader.CacheHash 236 local function hash_eq(a, b) 237 return a 238 and b 239 and a.size == b.size 240 and a.mtime.sec == b.mtime.sec 241 and a.mtime.nsec == b.mtime.nsec 242 end 243 244 --- `loadfile` using the cache 245 --- Note this has the mode and env arguments which is supported by LuaJIT and is 5.1 compatible. 246 --- @param filename? string 247 --- @param mode? "b"|"t"|"bt" 248 --- @param env? table 249 --- @return function?, string? error_message 250 local function loadfile_cached(filename, mode, env) 251 local modpath = normalize(filename) 252 local stat = fs_stat_cached(modpath) 253 local cname = cache_filename(modpath) 254 if stat then 255 local e_hash, e_chunk = read_cachefile(cname) 256 if hash_eq(e_hash, stat) and e_chunk then 257 -- found in cache and up to date 258 local chunk, err = load(e_chunk, '@' .. modpath, mode, env) 259 if not (err and err:find('cannot load incompatible bytecode', 1, true)) then 260 return chunk, err 261 end 262 end 263 end 264 265 local chunk, err = _loadfile(modpath, mode, env) 266 if chunk and stat then 267 write_cachefile(cname, stat, chunk) 268 end 269 return chunk, err 270 end 271 272 --- Return the top-level \`/lua/*` modules for this path 273 --- @param path string path to check for top-level Lua modules 274 local function lsmod(path) 275 if not indexed[path] then 276 indexed[path] = {} 277 for name, t in fs.dir(path .. '/lua') do 278 local modpath = path .. '/lua/' .. name 279 -- HACK: type is not always returned due to a bug in luv 280 t = t or fs_stat_cached(modpath).type 281 --- @type string 282 local topname 283 local ext = name:sub(-4) 284 if ext == '.lua' or ext == '.dll' then 285 topname = name:sub(1, -5) 286 elseif name:sub(-3) == '.so' then 287 topname = name:sub(1, -4) 288 elseif t == 'link' or t == 'directory' then 289 topname = name 290 end 291 if topname then 292 indexed[path][topname] = { modpath = modpath, modname = topname } 293 end 294 end 295 end 296 return indexed[path] 297 end 298 299 --- Finds Lua modules for the given module name. 300 --- 301 --- @since 0 302 --- 303 --- @param modname string Module name, or `"*"` to find the top-level modules instead 304 --- @param opts? vim.loader.find.Opts Options for finding a module: 305 --- @return vim.loader.ModuleInfo[] 306 function M.find(modname, opts) 307 opts = opts or {} 308 309 modname = modname:gsub('/', '.') 310 local basename = modname:gsub('%.', '/') 311 local idx = modname:find('.', 1, true) 312 313 -- HACK: fix incorrect require statements. Really not a fan of keeping this, 314 -- but apparently the regular Lua loader also allows this 315 if idx == 1 then 316 modname = modname:gsub('^%.+', '') 317 basename = modname:gsub('%.', '/') 318 idx = modname:find('.', 1, true) 319 end 320 321 -- get the top-level module name 322 local topmod = idx and modname:sub(1, idx - 1) or modname 323 324 -- OPTIM: search for a directory first when topmod == modname 325 local patterns = opts.patterns 326 or (topmod == modname and { '/init.lua', '.lua' } or { '.lua', '/init.lua' }) 327 for p, pattern in ipairs(patterns) do 328 patterns[p] = '/lua/' .. basename .. pattern 329 end 330 331 --- @type vim.loader.ModuleInfo[] 332 local results = {} 333 334 -- Only continue if we haven't found anything yet or we want to find all 335 local function continue() 336 return #results == 0 or opts.all 337 end 338 339 -- Checks if the given paths contain the top-level module. 340 -- If so, it tries to find the module path for the given module name. 341 --- @param paths string[] 342 local function _find(paths) 343 for _, path in ipairs(paths) do 344 if topmod == '*' then 345 for _, r in pairs(lsmod(path)) do 346 results[#results + 1] = r 347 if not continue() then 348 return 349 end 350 end 351 elseif lsmod(path)[topmod] then 352 for _, pattern in ipairs(patterns) do 353 local modpath = path .. pattern 354 stats.find.stat = (stats.find.stat or 0) + 1 355 local stat = fs_stat_cached(modpath) 356 if stat then 357 results[#results + 1] = { modpath = modpath, stat = stat, modname = modname } 358 if not continue() then 359 return 360 end 361 end 362 end 363 end 364 end 365 end 366 367 -- always check the rtp first 368 if opts.rtp ~= false then 369 _find(rtp_cached or {}) 370 if continue() then 371 local rtp, updated = get_rtp() 372 if updated then 373 _find(rtp) 374 end 375 end 376 end 377 378 -- check any additional paths 379 if continue() and opts.paths then 380 _find(opts.paths) 381 end 382 383 if #results == 0 then 384 -- module not found 385 stats.find.not_found = stats.find.not_found + 1 386 end 387 388 return results 389 end 390 391 --- Resets the cache for the path, or all the paths if path is nil. 392 --- 393 --- @since 0 394 --- 395 --- @param path string? path to reset 396 function M.reset(path) 397 if path then 398 indexed[normalize(path)] = nil 399 else 400 indexed = {} 401 end 402 403 -- Path could be a directory so just clear all the hashes. 404 if fs_stat_cache then 405 fs_stat_cache = {} 406 end 407 end 408 409 --- Enables or disables the experimental Lua module loader: 410 --- 411 --- Enable (`enable=true`): 412 --- * overrides |loadfile()| 413 --- * adds the Lua loader using the byte-compilation cache 414 --- * adds the libs loader 415 --- * removes the default Nvim loader 416 --- 417 --- Disable (`enable=false`): 418 --- * removes the loaders 419 --- * adds the default Nvim loader 420 --- 421 --- @since 0 422 --- 423 --- @param enable? (boolean) true/nil to enable, false to disable 424 function M.enable(enable) 425 enable = enable == nil and true or enable 426 if enable == M.enabled then 427 return 428 end 429 M.enabled = enable 430 431 if enable then 432 vim.fn.mkdir(vim.fs.abspath(M.path), 'p') 433 _G.loadfile = loadfile_cached 434 -- add Lua loader 435 table.insert(loaders, 2, loader_cached) 436 -- add libs loader 437 table.insert(loaders, 3, loader_lib_cached) 438 -- remove Nvim loader 439 for l, loader in ipairs(loaders) do 440 if loader == vim._load_package then 441 table.remove(loaders, l) 442 break 443 end 444 end 445 else 446 _G.loadfile = _loadfile 447 for l, loader in ipairs(loaders) do 448 if loader == loader_cached or loader == loader_lib_cached then 449 table.remove(loaders, l) 450 end 451 end 452 table.insert(loaders, 2, vim._load_package) 453 end 454 end 455 456 --- @deprecated 457 function M.disable() 458 vim.deprecate('vim.loader.disable', 'vim.loader.enable(false)', '0.12') 459 vim.loader.enable(false) 460 end 461 462 --- Tracks the time spent in a function 463 --- @generic F: function 464 --- @param f F 465 --- @return F 466 local function track(stat, f) 467 return function(...) 468 local start = uv.hrtime() 469 local r = { f(...) } 470 stats[stat] = stats[stat] or { total = 0, time = 0 } 471 stats[stat].total = stats[stat].total + 1 472 stats[stat].time = stats[stat].time + uv.hrtime() - start 473 return unpack(r, 1, table.maxn(r)) 474 end 475 end 476 477 --- @class (private) vim.loader._profile.Opts 478 --- @field loaders? boolean Add profiling to the loaders 479 480 --- Debug function that wraps all loaders and tracks stats 481 --- Must be called before vim.loader.enable() 482 --- @private 483 --- @param opts vim.loader._profile.Opts? 484 function M._profile(opts) 485 get_rtp = track('get_rtp', get_rtp) 486 read_cachefile = track('read', read_cachefile) 487 loader_cached = track('loader', loader_cached) 488 loader_lib_cached = track('loader_lib', loader_lib_cached) 489 loadfile_cached = track('loadfile', loadfile_cached) 490 M.find = track('find', M.find) 491 lsmod = track('lsmod', lsmod) 492 493 if opts and opts.loaders then 494 for l, loader in pairs(loaders) do 495 local loc = debug.getinfo(loader, 'Sn').source:gsub('^@', '') 496 loaders[l] = track('loader ' .. l .. ': ' .. loc, loader) 497 end 498 end 499 end 500 501 --- Prints all cache stats 502 --- @param opts? {print?:boolean} 503 --- @return vim.loader.Stats 504 --- @private 505 function M._inspect(opts) 506 if opts and opts.print then 507 local function ms(nsec) 508 return math.floor(nsec / 1e6 * 1000 + 0.5) / 1000 .. 'ms' 509 end 510 local chunks = {} --- @type string[][] 511 for _, stat in vim.spairs(stats) do 512 vim.list_extend(chunks, { 513 { '\n' .. stat .. '\n', 'Title' }, 514 { '* total: ' }, 515 { tostring(stat.total) .. '\n', 'Number' }, 516 { '* time: ' }, 517 { ms(stat.time) .. '\n', 'Bold' }, 518 { '* avg time: ' }, 519 { ms(stat.time / stat.total) .. '\n', 'Bold' }, 520 }) 521 for k, v in pairs(stat) do 522 if not vim.list_contains({ 'time', 'total' }, k) then 523 chunks[#chunks + 1] = { '* ' .. k .. ':' .. string.rep(' ', 9 - #k) } 524 chunks[#chunks + 1] = { tostring(v) .. '\n', 'Number' } 525 end 526 end 527 end 528 vim.api.nvim_echo(chunks, true, {}) 529 end 530 return stats 531 end 532 533 return M