feat(lua): vim.fs.slug() #41005

Problem:
Several subsystems need to derive a short, filesystem-safe identifier from an
arbitrary path, and each reinvents it ad-hoc:
- `'undodir'` and `swapfiles` encode the full path into a single filename, which
  may exceed filesystem length-limits.
- `:connect ssh://` needs the SSH ControlPath socket name to stay under the
  104-byte `sun_path` limit on macOS; today the path overflows it.
- the upcoming :terminal state dir.
- arbitrary plugin purposes.

Solution:
Provide `vim.fs.slug()`, which generates a bounded, one-way filename from an
arbitrary string. The input is normalized so equivalent paths produce the
same result. An 8-char hash is appended for uniqueness
This commit is contained in:
Willaaaaaaa
2026-08-05 03:24:35 +08:00
committed by GitHub
parent 90786766d2
commit 7b03df5d54
4 changed files with 235 additions and 0 deletions

View File

@@ -2951,6 +2951,48 @@ vim.fs.root({source}, {marker}) *vim.fs.root()*
(`string?`) Directory path containing one of the given markers, or nil
if no directory was found.
vim.fs.slug({path}, {opts}) *vim.fs.slug()*
Generates a bounded, filesystem-safe filename from an arbitrary identity
string.
• The input is normalized via |vim.fs.normalize()| so that equivalent
paths produce the same result (e.g., `~/foo` and `/home/username/foo`).
• `$HOME` is replaced with `~`. On Windows, UNC paths are replaced with
`=unc-`.
• An 8-character hex hash (|sha256()|) of the normalized input is appended
to prevent collisions.
• Unsafe characters (`/ \ : * ? " < > |`, whitespace, control characters)
are replaced with `-`, and trailing `-` and `.` are stripped.
• If `opts.maxlen` is exceeded, the result will be truncated to
`{head}~~~{tail}-{hash8}`.
• If the sanitized name is empty, the reserved label `=special` will be
used.
Examples: >lua
vim.fs.slug('/tmp/test/foo.md')
--> "tmp-test-foo.md-{hash}"
vim.fs.slug('C:/src/project/main.c')
--> "C--src-project-main.c-{hash}"
vim.fs.slug(('/a/very/long/path'):rep(10) .. '/file.txt', { maxlen = 60 })
--> "a-very-long-~~~-path-a-very-long-path-file.txt-{hash}"
vim.fs.slug('home/username/file.txt')
--> "~-file.txt-{hash}"
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {path} (`string`) a string that is not filesystem-safe.
• {opts} (`table?`) Optional parameters:
• maxlen: (integer) Max byte length of the result. Default is
180. Value must be at least 8.
Return: ~
(`string`) Filesystem-safe file name
==============================================================================
Lua module: vim.glob *vim.glob*

View File

@@ -343,6 +343,8 @@ LUA
• |vim.fs.find()| returns a list of errors as its second return value.
• |vim.fs.mkdir()| creates directories, including parent directories with
`opts.parents=true`.
• |vim.fs.slug()| generates a filesystem-safe file name from an arbitrary
identity string, with an optional length bound.
• |vim.filetype.inspect()| returns a copy of the internal tables used for
filetype detection.
• Added `__eq` metamethod to |vim.VersionRange|. 2 distinct but representing

View File

@@ -147,6 +147,124 @@ function M.joinpath(...)
return (path:gsub(iswin and '[/\\][/\\]*' or '//+', '/'))
end
--- Generates a bounded, filesystem-safe filename from an arbitrary identity string.
---
--- - The input is normalized via |vim.fs.normalize()| so that equivalent paths produce the same
--- result (e.g., `~/foo` and `/home/username/foo`).
--- - `$HOME` is replaced with `~`. On Windows, UNC paths are replaced with `=unc-`.
--- - An 8-character hex hash (|sha256()|) of the normalized input is appended to prevent
--- collisions.
--- - Unsafe characters (`/ \ : * ? " < > |`, whitespace, control characters) are replaced with
--- `-`, and trailing `-` and `.` are stripped.
--- - If `opts.maxlen` is exceeded, the result will be truncated to `{head}~~~{tail}-{hash8}`.
--- - If the sanitized name is empty, the reserved label `=special` will be used.
---
--- Examples:
---
--- ```lua
--- vim.fs.slug('/tmp/test/foo.md')
--- --> "tmp-test-foo.md-{hash}"
---
--- vim.fs.slug('C:/src/project/main.c')
--- --> "C--src-project-main.c-{hash}"
---
--- vim.fs.slug(('/a/very/long/path'):rep(10) .. '/file.txt', { maxlen = 60 })
--- --> "a-very-long-~~~-path-a-very-long-path-file.txt-{hash}"
---
--- vim.fs.slug('home/username/file.txt')
--- --> "~-file.txt-{hash}"
--- ```
---
---@since 15
---@param path string a string that is not filesystem-safe.
---@param opts? table Optional parameters:
--- - maxlen: (integer) Max byte length of the result. Default is 180. Value must be at least 8.
---@return string # Filesystem-safe file name
function M.slug(path, opts)
vim.validate('path', path, 'string')
opts = opts or {}
vim.validate('maxlen', opts.maxlen, function(v)
if v == nil then
return true
end
return type(v) == 'number' and v >= 8
end, '`opt.maxlen` must be at least 8')
opts.maxlen = opts.maxlen or 180
-- Normalize before computing the hash so equivalent paths produce the same result
path = vim.fs.normalize(path, { expand_env = false })
local s = path
-- Replace $HOME with `~`
-- `fnamemodify` resolves relative paths against CWD, so only call it on absolute paths
if vim.startswith(s, '/') or (iswin and s:match('^%w:/')) then
s = vim.fn.fnamemodify(s, ':~')
end
-- Replace UNC `//...` (Windows only) with `=unc-`
-- `//?/` and `//./` are NT namespace prefixes, not UNC
if
iswin
and vim.startswith(s, '//')
and not vim.startswith(s, '//?/')
and not vim.startswith(s, '//./')
then
s = '=unc-' .. s:sub(3)
end
-- Sanitize unsafe chars and trim trailing "-" and "."
s = s:gsub('[%c%s/\\:*?"<>|]', '-')
s = s:gsub('[.-]+$', '')
-- Strip the leading "-" from an absolute path
s = s:gsub('^-', '')
-- Always compute the hash to prevent collisions
local hash8 = vim.fn.sha256(path):sub(1, 8)
-- Fully scrubbed path uses the reserved prefix
if s == '' then
s = '=special'
end
-- Within maxlen: "{name}-{hash8}"
local maxlen = opts.maxlen
if #s + 1 + #hash8 <= maxlen then
return s .. '-' .. hash8
end
-- "{head}~~~{tail}-{hash8}"
local budget = maxlen - 12 -- 3 for "~~~", 1 for "-", 8 for hash
if budget < 1 then
-- No room for a readable form: degrade to a plain hash
return hash8:sub(1, maxlen)
end
local head_len = math.floor(budget / 3)
local h = s:sub(1, head_len):match('^.*()-') or head_len -- byte position where {head} ends
if h == head_len and h >= 1 then
-- No "-" found in prefix: ensure we don't split a UTF-8 character.
-- `vim.str_utf_start` returns an offset (<= 0) from the byte position to the character start.
-- `vim.str_utf_end` returns an offset (>= 0) to the character's last byte.
local char_start = h + vim.str_utf_start(s, h) ---@type integer
if char_start + vim.str_utf_end(s, char_start) > h then
h = char_start - 1
end
end
local tail_start = #s - budget + h + 1
if tail_start < 1 then
tail_start = 1
end
local t = s:find('-', tail_start, true) or tail_start -- byte position where {tail} starts
-- If we fall back to a byte position, step forward past a split character
if t == tail_start and t <= #s then
local offset_start = vim.str_utf_start(s, t)
if offset_start < 0 then
local char_start = t + offset_start ---@type integer
t = char_start + vim.str_utf_end(s, char_start) + 1
end
end
return s:sub(1, h) .. '~~~' .. s:sub(t) .. '-' .. hash8
end
--- Wrapper around `uv.fs_scandir_next()` that ensures a file type is returned.
---
--- @param fs uv.uv_fs_t

View File

@@ -666,6 +666,79 @@ describe('vim.fs', function()
end)
end)
describe('slug()', function()
it('replaces unsafe characters with "-"', function()
-- `\` is normalized to `/` on Windows, so the hash differs per platform
eq(
'a-b-c-d-e-f-g-h-i-j-k-l-'
.. vim.fn.sha256(vim.fs.normalize('a/b\\c:d*e?f"g<h>i|j k\tl')):sub(1, 8),
vim.fs.slug('a/b\\c:d*e?f"g<h>i|j k\tl')
)
eq('a-16b8a9f5', vim.fs.slug('a/ '))
eq('a-ca978112', vim.fs.slug('a/.'))
end)
it('works without args', function()
-- `=special`
eq('=special-8a5edab2', vim.fs.slug('/'))
eq('=special-ab5df625', vim.fs.slug('...'))
eq('=special-11d925ec', vim.fs.slug('-------'))
eq('src-foo-init.lua-cf05d2fe', vim.fs.slug('/src/foo/init.lua'))
-- Windows paths normalize differently on Windows vs Unix
eq('C--src-project-main.c-3b0eb5f5', vim.fs.slug('C:/src/project/main.c'))
eq('con.txt-d3bde286', vim.fs.slug('con.txt'))
-- Windows reserved names.
eq('con-1143da2b', vim.fs.slug('con'))
eq('com¹-bdcbdc69', vim.fs.slug('com¹'))
eq('NUL-ae6de182', vim.fs.slug('NUL'))
eq('Prn-0d399452', vim.fs.slug('Prn'))
eq('aux-321f6814', vim.fs.slug('aux'))
eq('con.foo.bar-f386c405', vim.fs.slug('con.foo.bar'))
eq('con-.-txt-48f98581', vim.fs.slug('con . txt'))
-- $HOME is replaced with `~`
local p = vim.uv.os_homedir() .. '/my-project'
local hash8_2 = vim.fn.sha256(vim.fs.normalize(p)):sub(1, 8)
eq('~-my-project-' .. hash8_2, vim.fs.slug(p))
end)
it('works with `opt.maxlen`', function()
-- maxlen < 8 is an error
t.matches('`opt.maxlen` must be at least 8', t.pcall_err(vim.fs.slug, 'foo', { maxlen = 7 }))
eq('2c26b46b', vim.fs.slug('foo', { maxlen = 8 }))
eq('2c26b46b', vim.fs.slug('foo', { maxlen = 11 }))
eq('foo-2c26b46b', vim.fs.slug('foo', { maxlen = 12 }))
eq('ddab29ff', vim.fs.slug('foo.txt', { maxlen = 12 }))
eq('foo-2c26b46b', vim.fs.slug('foo', { maxlen = 13 }))
-- truncates to "{head}~~~{tail}-{hash8}"
eq(
'aaaa-~~~-ffff-gggg-file.txt-7bd52057',
vim.fs.slug('/aaaa/bbbb/cccc/dddd/eeee/ffff/gggg/file.txt', { maxlen = 40 })
)
eq('测~~~-测试3-58cf4a70', vim.fs.slug('/测试1/测试测试2/测试3', { maxlen = 25 }))
eq('ab~~~uvwxyz-71c480df', vim.fs.slug('abcdefghijklmnopqrstuvwxyz', { maxlen = 20 }))
eq('dir~~~试.md-d4141f20', vim.fs.slug(('dir/'):rep(20) .. '测试.md', { maxlen = 22 }))
eq('~~~试abc-ab138cd6', vim.fs.slug('测试abc测试abc', { maxlen = 18 }))
eq('~~~d-473a1da7', vim.fs.slug('foo/bar/longlonglong.md', { maxlen = 13 }))
eq('f~~~md-473a1da7', vim.fs.slug('foo/bar/longlonglong.md', { maxlen = 15 }))
end)
it('works on Windows', function()
if t.skip(not is_os('win'), 'N/A Windows only') then
return
end
eq('=unc-foo-dir-file-549fb6e7', vim.fs.slug([[\\foo\dir\file]]))
-- `\\?\` and `\\.\`
eq(
'---Volume{a1b2c3d4-aa00-4000-a111-1a2b3c4d5e6f}-dir-file-cfda6a98',
vim.fs.slug([[\\?\Volume{a1b2c3d4-aa00-4000-a111-1a2b3c4d5e6f}\dir\file]])
)
eq('---C--dir-file-e8f30888', vim.fs.slug([[\\?\C:\dir\file]]))
eq('-.-COM1-e0e5710d', vim.fs.slug([[\\.\COM1]]))
end)
end)
describe('normalize()', function()
it('removes trailing /', function()
eq('/home/user', vim.fs.normalize('/home/user/'))