From 7b03df5d5402bc0564b77b5e660a3224bcbb484f Mon Sep 17 00:00:00 2001 From: Willaaaaaaa Date: Wed, 5 Aug 2026 03:24:35 +0800 Subject: [PATCH] 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 --- runtime/doc/lua.txt | 42 ++++++++++++ runtime/doc/news.txt | 2 + runtime/lua/vim/fs.lua | 118 ++++++++++++++++++++++++++++++++ test/functional/lua/fs_spec.lua | 73 ++++++++++++++++++++ 4 files changed, 235 insertions(+) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 058a1dc4f2..2a26270a83 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -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* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 18c5b7e628..5f32b79eb6 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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 diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 4788776660..afa7cb3000 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -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 diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index d49e6fe152..3c12672fa4 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -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"gi|j k\tl')):sub(1, 8), + vim.fs.slug('a/b\\c:d*e?f"gi|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/'))