From 886a270e7f4162933e180d3d96c3ef7ea162d4a0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 31 Jul 2026 23:52:07 -0500 Subject: [PATCH] refactor(zip)!: address entries with zip:// paths Problem: `zipfile://{archive}::{entry}` is ambiguous: `::` is legal in both an archive path and an entry path, so the separator cannot be identified. Splitting at the last `::` reads archives correctly but breaks entries that contain it, and the plugin then emits buffer names it cannot read back. Solution: Address entries as `zip://{archive}/{entry}`, joining the two paths. Resolve the split by walking components: the first one that is a regular file is the archive, because a regular file cannot have children on disk. Entry paths may then contain any character, and no escaping is needed. zipPlugin.vim keeps its own scheme, so the Java ftplugin emits whichever form matches the active plugin until the legacy package is removed. --- runtime/doc/zip.txt | 2 +- runtime/ftplugin/java.vim | 13 ++++++--- runtime/lua/nvim/zip.lua | 41 ++++++++++++++++------------- runtime/plugin/zip.lua | 2 +- test/functional/plugin/zip_spec.lua | 24 +++++++++-------- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/runtime/doc/zip.txt b/runtime/doc/zip.txt index 03dc389f8b..fbaf77c405 100644 --- a/runtime/doc/zip.txt +++ b/runtime/doc/zip.txt @@ -8,7 +8,7 @@ Builtin plugin: zip *zip* Nvim opens a read-only listing when |:edit| is used with a zip archive. The listing is a |dir| buffer with 'filetype' set to "zip"; entries open as -read-only `zipfile://{archive}::{path}` buffers. Requires the `unzip` +read-only `zip://{archive}/{path}` buffers. Requires the `unzip` executable. Recognized extensions include zip, jar, apk, epub, Office and OpenDocument diff --git a/runtime/ftplugin/java.vim b/runtime/ftplugin/java.vim index 00d0532dd6..509f5d146b 100644 --- a/runtime/ftplugin/java.vim +++ b/runtime/ftplugin/java.vim @@ -80,8 +80,11 @@ if exists("g:ftplugin_java_source_path") && function! JavaFileTypeZipFile() abort let l:member = substitute(v:fname, '\.', '/', 'g') . '.java' - return 'zipfile://' . get(s:zip_files, bufnr('%'), s:zip_files[0]) . - \ '::' . l:member + let l:archive = get(s:zip_files, bufnr('%'), s:zip_files[0]) + " The builtin plugin joins the paths; zipPlugin.vim separates them with "::". + return exists('#nvim.zip') + \ ? 'zip://' . l:archive . '/' . l:member + \ : 'zipfile://' . l:archive . '::' . l:member endfunction " E120 for "inex=s:JavaFileTypeZipFile()" before v8.2.3900. @@ -391,7 +394,11 @@ if exists("s:zip_func_upgradable") def! s:JavaFileTypeZipFile(): string const member: string = substitute(v:fname, '\.', '/', 'g') .. '.java' - return 'zipfile://' .. get(zip_files, bufnr('%'), zip_files[0]) .. '::' .. member + const archive: string = get(zip_files, bufnr('%'), zip_files[0]) + # The builtin plugin joins the paths; zipPlugin.vim separates them with "::". + return exists('#nvim.zip') + ? 'zip://' .. archive .. '/' .. member + : 'zipfile://' .. archive .. '::' .. member enddef setlocal includeexpr=s:JavaFileTypeZipFile() diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua index 47f7da80ec..32eb97476c 100644 --- a/runtime/lua/nvim/zip.lua +++ b/runtime/lua/nvim/zip.lua @@ -307,33 +307,36 @@ local function read_tempfile(buf, temp) set_readonly(buf) end ---- Parse a `zipfile://` buffer name, as used by quickfix and direct `:edit`. ----@param name string `zipfile://{archive}::{path}` +--- Resolve a `zip://` buffer name, as used by quickfix and direct `:edit`. +--- +--- The archive path and the entry path are simply joined, so the split is found by walking +--- components: the first one that is a regular file is the archive, because a regular file +--- cannot have children on disk. Entry paths may therefore contain any character. +---@param name string `zip://{archive}/{path}` ---@return string?, string? archive and entry path -local function parse_uri(name) - if not vim.startswith(name, 'zipfile://') then +local function resolve_uri(name) + if not vim.startswith(name, 'zip://') then return end - local value = name:sub(11) - local separator ---@type integer? + local value = name:sub(7) local offset = 1 while true do - local next_separator = value:find('::', offset, true) - if not next_separator then - break + local separator = value:find('/', offset + 1, true) + if not separator then + return end - separator = next_separator - offset = next_separator + 2 + local archive = value:sub(1, separator - 1) + local stat = uv.fs_stat(archive) + if stat and stat.type == 'file' then + return archive, value:sub(separator + 1) + end + offset = separator end - if not separator then - return - end - return value:sub(1, separator - 1), value:sub(separator + 2) end ---@class (private) nvim.zip.State ---@field source string Path to the archive. ----@field path? string Archive path shown by a `zipfile://` buffer. +---@field path? string Archive path shown by a `zip://` buffer. ---@field paths? string[] Entry paths carried over from the initial listing. ---@field prefix? string Archive directory currently listed. ---@field pending_prefix? string Prefix to commit once the backend succeeds. @@ -388,13 +391,13 @@ end --- Read one archive entry into a read-only buffer. ---@param buf integer Target entry buffer. ----@param name string `zipfile://` buffer name. +---@param name string `zip://` buffer name. function M.read(buf, name) buf = vim._resolve_bufnr(buf) local state = get_state(buf) local source, path = state and state.source, state and state.path if not source or not path then - source, path = parse_uri(name) + source, path = resolve_uri(name) end if not source or not path then set_readonly(buf) @@ -480,7 +483,7 @@ function M.open(buf, name, entry) require('nvim.dir').open(buf, name, M) return end - local uri = ('zipfile://%s::%s'):format(state.source, path) + local uri = ('zip://%s/%s'):format(state.source, path) local entry_buf = vim.fn.bufadd(uri) set_state(entry_buf, { source = state.source, path = path }) api.nvim_cmd({ diff --git a/runtime/plugin/zip.lua b/runtime/plugin/zip.lua index 8ba5aaa6c6..dc3f09c9c9 100644 --- a/runtime/plugin/zip.lua +++ b/runtime/plugin/zip.lua @@ -86,7 +86,7 @@ end api.nvim_create_autocmd('BufReadCmd', { group = group, - pattern = 'zipfile://*', + pattern = 'zip://*', desc = 'Read zip archive entry', callback = function(ev) if legacy_loaded() then diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua index 41f87370fb..e8ce394e8f 100644 --- a/test/functional/plugin/zip_spec.lua +++ b/test/functional/plugin/zip_spec.lua @@ -196,7 +196,7 @@ describe('nvim.zip', function() it('opens entries at quickfix locations', function() local archive = stage(fixtures, 'browser.zip') clear_zip() - local uri = ('zipfile://%s::crlf.txt'):format(archive) + local uri = ('zip://%s/crlf.txt'):format(archive) fn.setqflist({}, 'r', { items = { { filename = uri, lnum = 2, col = 1 } } }) api.nvim_cmd({ cmd = 'cfirst' }, {}) @@ -220,12 +220,12 @@ describe('nvim.zip', function() local archive = stage(fixtures, 'browser.zip') clear_zip() - edit(('zipfile://%s::crlf.txt'):format(archive)) + edit(('zip://%s/crlf.txt'):format(archive)) eq({ 'one', 'two' }, lines()) eq('dos', api.nvim_get_option_value('fileformat', { buf = 0 })) eq(true, api.nvim_get_option_value('endofline', { buf = 0 })) - edit(('zipfile://%s::noeol.txt'):format(archive)) + edit(('zip://%s/noeol.txt'):format(archive)) eq({ 'no final newline' }, lines()) eq(false, api.nvim_get_option_value('endofline', { buf = 0 })) end) @@ -300,7 +300,9 @@ describe('nvim.zip', function() feed('gf') poke_eventloop() - eq(('zipfile://%s::folder/root.java'):format(archive), api.nvim_buf_get_name(0)) + local uri = legacy and ('zipfile://%s::folder/root.java'):format(archive) + or ('zip://%s/folder/root.java'):format(archive) + eq(uri, api.nvim_buf_get_name(0)) eq({ 'class root {}' }, lines()) eq('java', api.nvim_get_option_value('filetype', { buf = 0 })) end @@ -323,7 +325,7 @@ describe('nvim.zip', function() { [[zipglob/a\\.txt]], [[a test file with a double \]] }, } for _, case in ipairs(cases) do - edit(('zipfile://%s::%s'):format(archive, case[1])) + edit(('zip://%s/%s'):format(archive, case[1])) eq({ case[2] }, lines()) end end) @@ -335,7 +337,7 @@ describe('nvim.zip', function() edit(archive) eq({ '-d/', 'pwned' }, lines()) - edit(('zipfile://%s::-d/tmp'):format(archive)) + edit(('zip://%s/-d/tmp'):format(archive)) eq({ '' }, lines()) end) @@ -354,7 +356,7 @@ describe('nvim.zip', function() poke_eventloop() eq({ 'nested payload' }, lines()) - local uri = ('zipfile://%s::crlf.txt'):format(archive) + local uri = ('zip://%s/crlf.txt'):format(archive) edit(uri) eq(uri, api.nvim_buf_get_name(0)) eq({ 'one', 'two' }, lines()) @@ -397,11 +399,11 @@ describe('nvim.zip', function() api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 }) feed('') poke_eventloop() - eq(('zipfile://%s::names/star*.txt'):format(archive), api.nvim_buf_get_name(0)) + eq(('zip://%s/names/star*.txt'):format(archive), api.nvim_buf_get_name(0)) eq({ 'content of star*.txt' }, lines()) for _, name in ipairs(names) do - edit(('zipfile://%s::names/%s'):format(archive, name)) + edit(('zip://%s/names/%s'):format(archive, name)) eq({ 'content of ' .. name }, lines()) end end) @@ -418,7 +420,7 @@ describe('nvim.zip', function() exec_lua(function(uri) vim.api.nvim_input('hunter2') vim.api.nvim_cmd({ cmd = 'edit', args = { uri }, magic = { file = false, bar = false } }, {}) - end, ('zipfile://%s::secret.txt'):format(archive)) + end, ('zip://%s/secret.txt'):format(archive)) poke_eventloop() eq({ 'secret content' }, lines()) @@ -432,7 +434,7 @@ describe('nvim.zip', function() exec_lua(function(uri) vim.api.nvim_input('no1no2no3') vim.api.nvim_cmd({ cmd = 'edit', args = { uri }, magic = { file = false, bar = false } }, {}) - end, ('zipfile://%s::secret.txt'):format(archive)) + end, ('zip://%s/secret.txt'):format(archive)) poke_eventloop() eq(true, exec_capture('messages'):find('incorrect password', 1, true) ~= nil)