Merge #41093 from barrettruth/feat/zip-finish

This commit is contained in:
Justin M. Keyes
2026-08-04 04:46:33 -04:00
committed by GitHub
5 changed files with 69 additions and 35 deletions
+1 -1
View File
@@ -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
+10 -3
View File
@@ -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()
+31 -19
View File
@@ -10,6 +10,15 @@ local function unzip()
if command == '' then
return nil, 'unzip executable not found'
end
-- Windows searches the current directory before $PATH, so an archive could be opened with an
-- `unzip` shipped next to it.
if vim.fn.has('win32') == 1 then
local dir = uv.fs_realpath(vim.fs.dirname(vim.fs.normalize(command)))
local cwd = uv.fs_realpath(vim.fn.getcwd())
if dir and cwd and dir == cwd then
return nil, 'refusing to run unzip from the current directory'
end
end
return command
end
@@ -302,33 +311,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.
@@ -383,13 +395,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)
@@ -475,7 +487,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({
+1 -1
View File
@@ -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
+26 -11
View File
@@ -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,11 +325,24 @@ 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)
it('runs the backend when the cwd is its directory', function()
t.skip(t.is_os('win'), 'N/A: Windows searches the cwd before $PATH')
local archive = stage(fixtures, 'browser.zip')
local bin = vim.fs.joinpath(assert(vim.uv.fs_realpath(root)), 'bin')
t.mkdir(bin)
assert(vim.uv.fs_symlink(vim.fn.exepath('unzip'), vim.fs.joinpath(bin, 'unzip')))
n.clear({ args = { '--clean' }, env = { PATH = bin .. ':' .. os.getenv('PATH') } })
api.nvim_set_current_dir(bin)
edit(archive)
eq('folder/', lines()[1])
end)
it('keeps a leading dash in a path from becoming a backend option', function()
local archive = stage(old_samples, 'poc.zip')
clear_zip()
@@ -335,7 +350,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 +369,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 +412,11 @@ describe('nvim.zip', function()
api.nvim_win_set_cursor(0, { line_of('star*.txt'), 0 })
feed('<CR>')
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 +433,7 @@ describe('nvim.zip', function()
exec_lua(function(uri)
vim.api.nvim_input('hunter2<CR>')
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 +447,7 @@ describe('nvim.zip', function()
exec_lua(function(uri)
vim.api.nvim_input('no1<CR>no2<CR>no3<CR>')
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)