doc/lib.nvim-fs.txt — rendered from the plugin's own vimdoc
*lib.nvim-fs.txt* Filesystem helpers: paths, scanning, IO, trash lib.nvim.fs *lib.nvim-fs* A flat directory of small, independent filesystem helpers — path predicates, normalization, recursive scanning (cached and uncached), JSON/text IO, safe entry creation, and cross-platform trash/open. There is nofs/init.luaaggregator: every submodule is required by its own full path, e.g.require("lib.nvim.fs.is_dir"). Most submodules return a function directly rather than a table — check each entry below.
CONTENTS
1. Design ......................................... |lib.nvim-fs-design| 2. Usage .......................................... |lib.nvim-fs-usage| 3. Predicates ..................................... |lib.nvim-fs-predicates| is_dir .................................... |lib.nvim-fs-is_dir| is_readable_file ........................ |lib.nvim-fs-is_readable_file| is_valid_filename ....................... |lib.nvim-fs-is_valid_filename| is_subpath .............................. |lib.nvim-fs-is_subpath| 4. Path utilities ................................. |lib.nvim-fs-path-utils| path ....................................... |lib.nvim-fs-path| path_shorten ............................ |lib.nvim-fs-path_shorten| relpath .................................... |lib.nvim-fs-relpath| normkey .................................... |lib.nvim-fs-normkey| project_key .............................. |lib.nvim-fs-project_key| globbable .................................. |lib.nvim-fs-globbable| 5. Root discovery .................................. |lib.nvim-fs-roots| find_root ................................ |lib.nvim-fs-find_root| find_upward_dir ....................... |lib.nvim-fs-find_upward_dir| stdpath_config_root ............. |lib.nvim-fs-stdpath_config_root| polymorphic_rootresolver .. |lib.nvim-fs-polymorphic_rootresolver| 6. Scanning ........................................ |lib.nvim-fs-scanning| collect_recursive .................... |lib.nvim-fs-collect_recursive| collect_async ............................ |lib.nvim-fs-collect_async| scan_cached ............................. |lib.nvim-fs-scan_cached| scan_roots ................................ |lib.nvim-fs-scan_roots| 7. IO ............................................... |lib.nvim-fs-io| read ........................................ |lib.nvim-fs-read| write.to_file ...................... |lib.nvim-fs-write.to_file| write.append ......................... |lib.nvim-fs-write.append| write.async ............................ |lib.nvim-fs-write.async| write.batch ............................. |lib.nvim-fs-write.batch| json ........................................ |lib.nvim-fs-json| mkdirp .................................... |lib.nvim-fs-mkdirp| create_entry ........................ |lib.nvim-fs-create_entry| 8. Ignore patterns ................................. |lib.nvim-fs-ignore| ignore.list ................................ |lib.nvim-fs-ignore.list| 9. System ........................................... |lib.nvim-fs-system| trash ...................................... |lib.nvim-fs-trash| 10. Watching ........................................ |lib.nvim-fs-watching| watch ...................................... |lib.nvim-fs-watch| 11. Working directory ............................... |lib.nvim-fs-cwd| chdir ....................................... |lib.nvim-fs-chdir| dir_guard ................................ |lib.nvim-fs-dir_guard|
1. DESIGN
* One responsibility per submodule; no shared aggregator table to keep requires tree-shake friendly. * Blocking IO uses binary mode ("rb"/"wb") so content round-trips byte-exact across platforms; async IO (write.async,write.batch,trash.trash) is libuv-based and safe to call from a fast-event context. * Errors are returned (ok, err), never thrown — callers decide whether a failure is worth surfacing. *scan_cached(in-memory, session-lifetime) andscan_roots(optional on-disk cache) both build oncollect_recursive, the one actual directory walker in this namespace — nothing else re-implements the walk.
2. USAGE
Every submodule is required individually:
local is_dir = require("lib.nvim.fs.is_dir")
local collect_recursive = require("lib.nvim.fs.collect_recursive")
local json = require("lib.nvim.fs.json")
Submodules that return a single function are called directly; submodules that return a table expose one or more named functions on it (each entry below states which).
3. PREDICATES
is_dir({p})
Function. Returnstrueiffpstats as a directory;falseotherwise (including on stat failure).
local is_dir = require("lib.nvim.fs.is_dir")
if is_dir("/repo/src") then ... end
is_readable_file({filepath})
Function.trueiffilepathis a readable file OR a directory;falseotherwise. Despite the name, directories pass too.
local is_readable_file = require("lib.nvim.fs.is_readable_file")
if is_readable_file("/repo/README.md") then ... end
is_valid_filename({name})
Function. Validates a bare filename (not a full path): rejects characters illegal on Windows (\ / : * ? " < > |), an embedded NUL, an empty or whitespace-only string. Returnsok, err.
local is_valid_filename = require("lib.nvim.fs.is_valid_filename")
local ok, err = is_valid_filename("new_file.lua")
is_subpath({path}, {base})
Function.trueifpath == baseorpathstarts withbase .. "/", aftervim.fs.normalize-ing both (always forward slashes).
local is_subpath = require("lib.nvim.fs.is_subpath")
is_subpath("/a/b/c", "/a/b") -- true
is_subpath("/a/bc", "/a/b") -- false
4. PATH UTILITIES
path
Table M:
local path = require("lib.nvim.fs.path")
local abs = path.from_repo_relative("lua/init.lua")
-- absolute path: tries <git repo root>/raw, then cwd-relative,
-- returns the first filereadable candidate (else the last one tried)
local joined = path.joinpath({ "a", "b", "c.lua" })
-- vim.fs.joinpath if available, else table.concat with the OS separator
local ok, err = path.ensure_dir("/tmp/new/deep/file.txt")
-- creates the PARENT directory of a file path; fast-event safe
path_shorten({path}, {max_len}, {opts})
Function. Shorten a path for display.
local path_shorten = require("lib.nvim.fs.path_shorten")
local short = path_shorten("/home/user/very/long/project/file.lua", 30)
local label = path_shorten("/home/user/project/src/file.lua", nil, { style = "label" })
Options
style "fit"|"label" "fit": preserve root + filename, collapse the
middle to fit max_len (default). "label":
always render <root>/<ellipsis>/<parent>/<file>,
ignoring max_len. Ported from Harpoon's
menu-label formatter.
ellipsis string collapsed-segment marker ("…" for fit,
"...." for label)
relpath({path}, {base})
Function.pathrelative tobase(both made absolute, forward-slashed first)...segments climb to the nearest common ancestor whenpathis not underbase; cross-drive Windows paths returnpathunchanged;path == baseyields".".
local relpath = require("lib.nvim.fs.relpath")
local rel = relpath("/repo/src/foo.lua", "/repo") -- "src/foo.lua"
normkey({p}, {opts})
Function. Canonical, cross-platform cache/dedup key for a path: expands ~,
optionally resolves symlinks, forces forward slashes, uppercases a Windows
drive letter, collapses duplicate separators (UNC-safe).
local normkey = require("lib.nvim.fs.normkey")
local key = normkey("~/project", { realpath = false })
Options
realpath boolean resolve symlinks via uv.fs_realpath (default true)
project_key({path})
Function. Stable per-project cache key: the Git root ofpath(default cwd) if inside a work-tree, elsepath/cwd itself — run throughnormkey. Uses the cached, marker-basedfind_rootrather than shelling out togiton every call.
local project_key = require("lib.nvim.fs.project_key")
local key = project_key() -- current project
local key2 = project_key("/repo") -- explicit path
globbable({root})
Function. Returnsrootspelled sovim.fn.glob/globpathreads it as a path, not a pattern. On Windows, a~in a glob pattern is a home-directory reference — an 8.3 short name likeC:/Users/STEFAN~1/...(what%TEMP%/vim.fn.tempname()expand to for long profile names) makes glob silently return an empty list. Resolves viauv.fs_realpath(skipped when there is no~to resolve, and whenrootdoes not exist). Other glob metacharacters ([,],*,?) in a directory name are not handled -- not portably escapable.
local globbable = require("lib.nvim.fs.globbable")
local safe_root = globbable(vim.fn.tempname())
vim.fn.glob(safe_root .. "/*")
5. ROOT DISCOVERY
find_root({opts})
Factory. Given a file/directory path, walks upward to the nearest ancestor holding any of the configured markers. Results are LRU-cached per directory.
local find_root = require("lib.nvim.fs.find_root")
local finder = find_root({ markers = { ".git", "*.rockspec" } })
local root = finder.find("/repo/src/a.lua") -- "/repo"
finder.clear() -- drop the cache
Options
markers string[] marker basenames or globs (default {".git"})
cache boolean enable the LRU cache (default true)
cache_chain boolean cache every ancestor visited, not just the
queried directory (default false)
capacity integer LRU capacity (default 256, or 512 when
cache_chain is true)
find_upward_dir({names}, {from})
Function. Walk upward fromfrom, return the nearest ancestor directory holding one ofnames(basenames, or*/?globs).
local find_upward_dir = require("lib.nvim.fs.find_upward_dir")
local root = find_upward_dir({ ".git", "*.rockspec" }, vim.fn.expand("%:p:h"))
stdpath_config_root({dir})
Function. The Neovim config directory if {dir} lies inside it, else nil.
The returned path is always a prefix of {dir}.
Both the normalized stdpath("config") and its uv.fs_realpath form are
tried, so a ~/.config/nvim that is a symlink into a dotfiles repo still
matches a directory taken from a buffer name — which Neovim canonicalizes on
Unix. Never stdpath("config") verbatim, on either branch: a plain match
returns the normalized form, the symlink branch the realpath-canonicalized one
-- on Windows the raw value carries native separators on every call (measured),
and returning it as-is would break the "genuine prefix of {dir}" promise above
for anyone on a plain match, not only the symlinked case. The realpath is
resolved once per stdpath("config") value, not per call -- keyed on that
string, so a symlink re-pointed at a different target while Neovim keeps
running is not seen until restart.
local stdpath_config_root = require("lib.nvim.fs.stdpath_config_root")
stdpath_config_root("/home/me/.config/nvim/lua") -- "/home/me/.config/nvim"
stdpath_config_root("/home/me/work/proj/src") -- nil
Used by |lib.nvim-fs-polymorphic_rootresolver| for cfg.include_stdpath_config.
polymorphic_rootresolver({cfg})
Factory. Returns a resolver function for LSP-style root detection, accepting either a buffer number or a filename.
local make_resolver = require("lib.nvim.fs.polymorphic_rootresolver")
local resolve_root = make_resolver({ markers = { ".git", "pyproject.toml" } })
local root = resolve_root("/home/user/project/src/main.lua")
local root_buf = resolve_root(0, function(res) print("root:", res) end)
Options
markers string[] root markers passed tovim.fs.root(default {".git", ".hg", ".svn"}) resolve fun(dir, cfg): string|nil replaces the marker search entirely, for servers whose root is more than "nearest marker"; pcall'd, and a nil/ error result falls back todiritself. Takes precedence overmarkerswhen set. include_stdpath_config boolean snap the root tostdpath("config")when it falls under it (default true) The returned resolver takes(arg, cb?):argis a buffer number or filename (empty falls back to cwd);cb, if given, is invoked with the resolved root in addition to it being returned synchronously.
6. SCANNING
collect_recursive
TableM. Recursive directory walker built onfs_scandir; returns a flat array of absolute paths.M.collect/M.files/M.dirs.
local collect_recursive = require("lib.nvim.fs.collect_recursive")
local all = collect_recursive.collect("/repo", { kind = "files" })
local files = collect_recursive.files("/repo", {
ignore = function(p) return p:match("/%.git$") ~= nil end,
})
local dirs = collect_recursive.dirs("/repo")
Options
kind "all"|"files"|"dirs" what to collect ignore fun(abs_path: string, is_dir: boolean): boolean prune a path (and, for a dir, its whole subtree) when it returns true collect_async({root}, {opts}, {on_done}) *lib.nvim-fs-collect_async* Non-blocking counterpart tocollect, same result and sameopts. The synchronous walk above blocks the caller until eachfs_scandir/fs_statsyscall returns — fine for a handful of directories, but stalls Neovim's main loop for the whole walk on a large tree (anode_modules, a monorepo).collect_asyncuses the async form of those same libuv calls, one directory at a time, driven by a small internal coroutine so the walk still reads like a plain recursive function — no callback pyramid — while control genuinely returns to the event loop between calls.files_async/dirs_asyncmirrorfiles/dirs.
local cancel = collect_recursive.collect_async("/repo", { kind = "files" }, function(paths)
-- called once, vim.schedule-dispatched
end)
-- cancel() -- stop early; on_done will not fire at all
Not a parallel scan and not necessarily a wall-clock speedup — the fix is for main-loop responsiveness during a large tree, not raw throughput; siblings are still visited sequentially, deliberately, to keep the coroutine driver simple. Returns acancel()function: calling it stops the walk after its current in-flight libuv call settles, andon_doneis not called at all afterward.
scan_cached({root}, {opts})
FunctionM.scan. In-memory, TTL-cached wrapper aroundcollect_recursivefor one root — the session-lifetime counterpart toscan_roots.
local scan_cached = require("lib.nvim.fs.scan_cached")
local files = scan_cached.scan("/repo/lua", { ttl_seconds = 5 })
local fresh = scan_cached.scan("/repo/lua", { ttl_seconds = 5, refresh = true })
Options
kind "files"|"dirs"|"all" default "files" ignore function forwarded tocollect_recursivettl_seconds integer cache freshness window (default 5) refresh boolean force a rescan, refreshing the cache Cache key isroot .. ":" .. kind; only the walk is cached, not any downstream per-path work.M.scan_async(root, opts, on_done)is the non-blocking counterpart: a cache miss walks viacollect_recursive.collect_asyncinstead of blocking; a cache hit still callson_done(vim.schedule-dispatched either way, so both branches behave the same to a caller).
scan_roots({roots}, {opts})
Function M.scan. Scan multiple root directories sequentially, merged into
one flat array, with an optional TTL-based **on-disk** JSON cache.
local scan_roots = require("lib.nvim.fs.scan_roots")
local files = scan_roots.scan({ "/repo/src", "/repo/lua" }, {
ignore_dirs = { "node_modules", ".git" },
cache_path = vim.fn.stdpath("cache") .. "/my_plugin/scan.json",
ttl_seconds = 60,
})
Options
ignore_dirs string[] directory names to skip (default {})
kind "files"|"dirs"|"all" default "files"
cache_path string when set, results persist to this
JSON file
ttl_seconds integer cache lifetime; omit for "never
expires until manually invalidated"
M.scan_async(roots, opts, on_done) is the non-blocking counterpart: same
options and cache semantics (the JSON cache file itself is still read/
written synchronously — one small file, not the part that scales badly),
roots still walked sequentially, one collect_async call chained into the
next.
7. IO
read({path})
Function. Read a whole file into a string (binary mode, byte-exact).
Returns content, err.
local read = require("lib.nvim.fs.read")
local content, err = read("/repo/README.md")
write.to_file({path}, {content})
Function. Truncate-writecontenttopath, creating parent directories. Adds a trailing newline when missing. Returnsok, err.
local write_to_file = require("lib.nvim.fs.write.to_file")
local ok, err = write_to_file("/tmp/out.txt", "hello world")
write.append({path}, {content})
Function. Appendcontenttopath(creating parent directories), adding a trailing newline when missing. Returnsok, err.
local write_append = require("lib.nvim.fs.write.append")
local ok, err = write_append("/tmp/log.txt", "new line")
write.async({path}, {content}, {cb})
Function. Non-blocking counterpart towrite.to_file, libuv-based.cbruns on the main loop viavim.schedule.
local write_async = require("lib.nvim.fs.write.async")
write_async("/tmp/out.txt", "hello", function(ok, err)
if not ok then vim.notify("write failed: " .. tostring(err)) end
end)
write.batch({entries}, {cb})
Function. Write many files asynchronously (built onwrite.async), one callback when all have finished.resultsis index-aligned withentries.
local write_batch = require("lib.nvim.fs.write.batch")
write_batch({
{ path = "/tmp/a.txt", content = "a" },
{ path = "/tmp/b.txt", content = "b" },
}, function(all_ok, results) end)
json
TableM. Read/write JSON files; writes are atomic (path .. ".tmp"then rename).
local json = require("lib.nvim.fs.json")
local ok, err = json.write("/tmp/state.json", { count = 1 })
local tbl, err2 = json.read("/tmp/state.json")
mkdirp({path})
Function. Recursivemkdir -pbuilt purely on libuv — safe to call from a fast-event context (uv timer/fs_event/subprocess callback), unlikevim.fn.mkdir. Returnsok, err.
local mkdirp = require("lib.nvim.fs.mkdirp")
local ok, err = mkdirp("/tmp/a/b/c")
create_entry({parent_dir}, {name})
Function. Create a file or directory relative toparent_dir— the shared core behind "create file/folder" picker actions. A trailing separator onnamecreates a directory (mkdir -p); otherwise an empty file is created. Returnsok, kind, path_or_err.
local create_entry = require("lib.nvim.fs.create_entry")
local ok, kind, path = create_entry("/repo/src", "new_file.lua")
local ok2, kind2, dir = create_entry("/repo/src", "sub/dir/")
8. IGNORE PATTERNS
ignore.list
TableM. Canonical, centralized filesystem ignore definitions shared across dev tooling (LSP indexing, pickers, trees, search). Heuristic and conservative — not a.gitignorereplacement.
local ignore = require("lib.nvim.fs.ignore.list")
require("telescope").setup({
defaults = { file_ignore_patterns = ignore.as_telescope_patterns() },
})
Fields / functions
basenames string[] exact ignored basenames (.git, node_modules, ...)
patterns string[] Lua-pattern ignores (%.pyc, %.log, ...)
normalize(s) string normalized basename for comparison
as_set() table basenames as a lookup set
as_luals_patterns() string[] Lua.workspace.ignoreDir-compatible globs
as_telescope_patterns() string[] basenames + patterns combined
as_neotree_names() string[] basenames only
9. SYSTEM
trash({path}, {cb})
TableM. Cross-platform "send to trash" (not a permanent delete): PowerShellFileSystemAPI on Windows, Finder viaosascripton macOS,gio trash/trash-puton Linux/WSL, with an XDG-trash-directoryfs_renamefallback when no trash tool is present.
local trash = require("lib.nvim.fs.trash")
trash.trash("/tmp/some_file.txt", function(ok, err)
if not ok then vim.notify(err, vim.log.levels.ERROR) end
end)
local ok, err = trash.trash_blocking("/tmp/some_dir")
M.trashis async (result viacb);M.trash_blockingblocks and returnsok, errdirectly. The fallback path does not write.trashinfometadata, so "restore from trash" UIs may not show the original location.
10. WATCHING
watch.start({path}, {on_change}, {opts})
Function. Watch a file or directory for filesystem changes viauv.new_fs_event(), debounced throughlib.nvim.debounceso one change (a save-via-rename, a build tool touching several files) does not fireon_changeseveral times in quick succession.
local watch = require("lib.nvim.fs.watch")
local handle, err = watch.start("/repo/lua", function(path, filename, events)
vim.notify(filename .. " changed under " .. path)
end, { debounce_ms = 200 })
handle.stop() -- safe to call more than once
Options
debounce_ms integer debounce window in ms (default 200)
11. WORKING DIRECTORY
chdir({path}, {opts})
Function. Scope-aware working-directory change: global (:cd), tab-local (:tcd) or window-local (:lcd) -- explicit wherevim.fn.chdir()is implicit (its scope depends on what the current window happens to have).pathis canonicalized through |lib.nvim-fs-normkey|; a path that is missing or not a directory is rejected before the command runs. Never throws.
local chdir = require("lib.nvim.fs.chdir")
chdir("~/projects/app") -- global
chdir("/repo", { scope = "tab" }) -- :tcd in the current tab
chdir("/repo", { scope = "win", win = winid }) -- :lcd in that window
Returnsok, err; the resolved path is not returned -- read it back withvim.fn.getcwd().
Options
scope "global"|"tab"|"win" default "global" win integer window to run the change in (scope "win"), default the current window tab integer tabpage whose current window is borrowed (scope "tab"); ignored whenwinis set
dir_guard.hold({path}, {opts})
Function. Hold the working directory onpathuntil released, undoing any foreign change viaDirChanged(a picker:lcd-ing into a result, a session restore, an LSP root-dir hook). Built on |lib.nvim-fs-chdir|; a hold watches exactly the scope it holds. Releasing does NOT restore the previous directory -- the guard holds a position, it does not own a stack.
local dir_guard = require("lib.nvim.fs.dir_guard")
local held = dir_guard.hold("/repo")
vim.cmd.cd("/tmp") -- undone: cwd is /repo again
held.bypass(function() vim.cmd.cd("/tmp") end) -- allowed, then restored
held.update("/other") -- move the pin
held.release()
Returnshandle, err(handleis nil when the initial change failed).handleexposespath(),is_held(),bypass(fn),update(new_path), andrelease().optsalso takes anon_violation(new_cwd, held)hook (returnfalseto accept a foreign change and release the guard instead of undoing it) andon_error(err)for a failed restore, plus every |lib.nvim-fs-chdir| scope option (scope,win,tab).