From e29af9c32f8be5767924b79612ce0f73877d2263 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:54:43 -0500 Subject: [PATCH] refactor(zip)!: builtin zip.lua plugin #40846 Problem: The bundled `zip` plugin is implemented in Vimscript, making it harder to maintain and build on with Nvim's Lua runtime infrastructure. Solution: Add an opt-out `zip.lua` browser backed by `nvim.dir`, and disable the legacy Vimscript implementation by moving it to `pack/dist/opt/zip/`. Load it with `:packadd zip`. --- runtime/doc/filetype.txt | 6 +- runtime/doc/news.txt | 4 + runtime/doc/plugins.txt | 3 +- runtime/doc/zip.txt | 55 +++ runtime/ftplugin/java.vim | 11 +- runtime/lua/nvim/zip.lua | 399 ++++++++++++++++++ runtime/lua/vim/_core/util.lua | 12 + .../pack/dist/opt/netrw/autoload/netrw.vim | 7 +- .../dist/opt/old-zip}/autoload/zip.vim | 0 .../dist/opt/old-zip}/doc/pi_zip.txt | 0 .../dist/opt/old-zip}/plugin/zipPlugin.vim | 0 runtime/plugin/net.lua | 6 +- runtime/plugin/zip.lua | 108 +++++ runtime/syntax/zip.lua | 12 + test/functional/fixtures/zip/browser.zip | Bin 0 -> 1143 bytes test/functional/lua/net_spec.lua | 41 +- test/functional/plugin/zip_spec.lua | 352 +++++++++++++++ test/old/testdir/test_plugin_zip.vim | 2 +- 18 files changed, 1001 insertions(+), 17 deletions(-) create mode 100644 runtime/doc/zip.txt create mode 100644 runtime/lua/nvim/zip.lua rename runtime/{ => pack/dist/opt/old-zip}/autoload/zip.vim (100%) rename runtime/{ => pack/dist/opt/old-zip}/doc/pi_zip.txt (100%) rename runtime/{ => pack/dist/opt/old-zip}/plugin/zipPlugin.vim (100%) create mode 100644 runtime/plugin/zip.lua create mode 100644 runtime/syntax/zip.lua create mode 100644 test/functional/fixtures/zip/browser.zip create mode 100644 test/functional/plugin/zip_spec.lua diff --git a/runtime/doc/filetype.txt b/runtime/doc/filetype.txt index 6f66087822..466370aa0d 100644 --- a/runtime/doc/filetype.txt +++ b/runtime/doc/filetype.txt @@ -786,14 +786,14 @@ in your vimrc: > JAVA *ft-java-plugin* + *g:ftplugin_java_source_path* Whenever the variable "g:ftplugin_java_source_path" is defined and its value is a filename whose extension is either ".jar" or ".zip", e.g.: > let g:ftplugin_java_source_path = '/path/to/src.jar' let g:ftplugin_java_source_path = '/path/to/src.zip' < -and the |zip| plugin has already been sourced, the |gf| command can be used to -open the archive and the |n| command can be used to look for the selected type -and the key can be used to load a listed file. +and a zip plugin has already been sourced, the |gf| command can be used to open +the selected entry. Note that the effect of using the "gf" command WITHIN a buffer loaded with the Zip plugin depends on the version of the Zip plugin. For the Zip plugin diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 99e70b2f3c..a3de344e1a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -372,6 +372,10 @@ PERFORMANCE PLUGINS +• The zip plugin is now |zip|, a read-only archive browser built on |dir|. + To use the legacy plugin instead: > + :packadd old-zip +< • provider: add bun support for Node.js plugins STARTUP diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt index 8523f3a032..671eac1fef 100644 --- a/runtime/doc/plugins.txt +++ b/runtime/doc/plugins.txt @@ -26,6 +26,7 @@ Help-link Loaded Short description ~ |matchit| Yes Extended |%| matching |matchparen| Yes Highlight matching pairs |netrw| Yes Reading and writing files over a network +|zip| Yes Read-only zip archive browser |package-cfilter| No Filtering quickfix/location list |package-justify| No Justify text |package-nohlsearch| No Automatically run :nohlsearch @@ -40,7 +41,7 @@ Help-link Loaded Short description ~ |pi_swapmouse| No Swap meaning of left and right mouse buttons |pi_tar.txt| Yes Tar file explorer |pi_tutor.txt| Yes Interactive tutorial -|pi_zip.txt| Yes Zip archive explorer +|old-zip| No Legacy zip archive explorer ============================================================================== Builtin plugin: dir *dir* diff --git a/runtime/doc/zip.txt b/runtime/doc/zip.txt new file mode 100644 index 0000000000..caf20cc236 --- /dev/null +++ b/runtime/doc/zip.txt @@ -0,0 +1,55 @@ +*zip.txt* Nvim + + + NVIM REFERENCE MANUAL + + +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". It requires the +`unzip` executable. The module implementing it is private and is not +a supported Lua API. + + *g:loaded_nvim_zip_plugin* +To disable the built-in zip browser, set this before startup: >lua + + vim.g.loaded_nvim_zip_plugin = 1 +< +ARCHIVE LISTINGS *zip-listing* + +Editing a zip-like file opens a read-only listing. Directory entries can be +opened to browse that level of the archive. File entries open as read-only +`zipfile://{archive}::{path}` buffers. + +Mappings are the standard |dir-buffer-mappings|. At the archive root, - opens +the containing filesystem directory. + +Recognized extensions include zip, jar, apk, epub, Office and OpenDocument +formats, whl, xpi, pkpass, and cbz. + +INTEGRATIONS *zip-integrations* + +Remote zip URLs are downloaded by |vim.net| and opened in the browser. + +|g:ftplugin_java_source_path| opens the selected entry with |gf|. + +LIMITATIONS *zip-limitations* + +Archive entries cannot be updated or extracted. Remote archives are not +refetched on reload, and there is no PowerShell fallback, no custom backend +command, and no support for the legacy `g:zip_*` variables. Use |old-zip| +when those features are required. + +============================================================================== +Legacy plugin: zip *old-zip* + +The legacy Vimscript zip plugin browses, updates, and extracts archive +entries. It is not loaded by default; use `:packadd` to activate it: >lua + + vim.cmd.packadd('old-zip') +< +This can be done at any time. Its manual is then available as +`:help pi_zip.txt`. + +vim:tw=78:ts=8:noet:ft=help diff --git a/runtime/ftplugin/java.vim b/runtime/ftplugin/java.vim index 4a08c57dd4..00d0532dd6 100644 --- a/runtime/ftplugin/java.vim +++ b/runtime/ftplugin/java.vim @@ -68,7 +68,7 @@ unlet! s:zip_func_upgradable if exists("g:ftplugin_java_source_path") && \ type(g:ftplugin_java_source_path) == type("") if filereadable(g:ftplugin_java_source_path) - if exists("#zip") && + if (exists("#zip") || exists("#nvim.zip")) && \ g:ftplugin_java_source_path =~# '.\.\%(jar\|zip\)$' if !exists("s:zip_files") let s:zip_files = {} @@ -79,8 +79,9 @@ if exists("g:ftplugin_java_source_path") && let s:zip_func_upgradable = 1 function! JavaFileTypeZipFile() abort - let @/ = substitute(v:fname, '\.', '\\/', 'g') . '.java' - return get(s:zip_files, bufnr('%'), s:zip_files[0]) + let l:member = substitute(v:fname, '\.', '/', 'g') . '.java' + return 'zipfile://' . get(s:zip_files, bufnr('%'), s:zip_files[0]) . + \ '::' . l:member endfunction " E120 for "inex=s:JavaFileTypeZipFile()" before v8.2.3900. @@ -389,8 +390,8 @@ if exists("s:zip_func_upgradable") delfunction! JavaFileTypeZipFile def! s:JavaFileTypeZipFile(): string - @/ = substitute(v:fname, '\.', '\\/', 'g') .. '.java' - return get(zip_files, bufnr('%'), zip_files[0]) + const member: string = substitute(v:fname, '\.', '/', 'g') .. '.java' + return 'zipfile://' .. get(zip_files, bufnr('%'), zip_files[0]) .. '::' .. member enddef setlocal includeexpr=s:JavaFileTypeZipFile() diff --git a/runtime/lua/nvim/zip.lua b/runtime/lua/nvim/zip.lua new file mode 100644 index 0000000000..7dd55b90dc --- /dev/null +++ b/runtime/lua/nvim/zip.lua @@ -0,0 +1,399 @@ +local api = vim.api +local uv = vim.uv +local notify = require('vim._core.util').notify + +local M = {} + +---@return string?, string? +local function unzip() + local command = vim.fn.exepath('unzip') + if command == '' then + return nil, 'unzip executable not found' + end + return command +end + +--- Escape a path passed to Info-ZIP, which expands glob patterns even without a shell. +--- https://github.com/neovim/neovim/blob/7ba955fe079d4aa2554fea8e7235651fafd40efb/runtime/autoload/zip.vim#L316-L339 +---@param value string +---@return string +local function literal_pattern(value) + return (value:gsub('\\', '\\\\'):gsub('%?', '\\?'):gsub('%*', '\\*'):gsub('%[', '[[]')) +end + +---@param source string +---@return string[]?, string?, boolean? +local function list_archive(source) + local command, command_err = unzip() + if not command then + return nil, command_err, false + end + local ok, system = pcall( + vim.system, + { command, '-Z1', '--', literal_pattern(source) }, + { text = true } + ) + if not ok then + return nil, tostring(system), false + end + local result = system:wait() + if result.code ~= 0 then + return nil, vim.trim(result.stderr or ''), true + end + return vim.split(result.stdout or '', '\n', { plain = true, trimempty = true }) +end + +--- Keep absolute and dot-segment entries visible without treating them as UI navigation. +---@param path string +---@return boolean +local function opaque_path(path) + if path:sub(1, 1) == '/' or path:match('^%a:[/\\]') then + return true + end + for component in path:gmatch('[^/]+') do + if component == '.' or component == '..' then + return true + end + end + return false +end + +--- Project the flat archive path list into one navigable directory level. +---@param paths string[] Raw entry paths from the archive. +---@param prefix string Raw archive-directory prefix, including its trailing slash. +---@return nvim.dir.Entry[] +local function entries_at(paths, prefix) + local entries = {} ---@type nvim.dir.Entry[] + local seen = {} ---@type table + for _, path in ipairs(paths) do + if prefix == '' and opaque_path(path) then + local key = 'opaque:' .. path + if not seen[key] then + seen[key] = true + entries[#entries + 1] = { name = path, dir = false } + end + elseif vim.startswith(path, prefix) then + local rest = path:sub(#prefix + 1) + local separator = rest:find('/', 1, true) + local name = separator and rest:sub(1, separator - 1) or rest + if name ~= '' then + local dir = separator ~= nil + local entry_path = ('%s%s%s'):format(prefix, name, dir and '/' or '') + local key = (dir and 'dir:' or 'file:') .. entry_path + if not seen[key] then + seen[key] = true + entries[#entries + 1] = { name = name, dir = dir } + end + end + end + end + return entries +end + +---@param source string +---@return boolean?, string? +local function has_magic(source) + local fd, err = uv.fs_open(source, 'r', 438) + if not fd then + return nil, err + end + local magic, read_err = uv.fs_read(fd, 2, 0) + uv.fs_close(fd) + if not magic then + return nil, read_err + end + return magic == 'PK' +end + +---@param buf integer +---@param source string +local function read_normally(buf, source) + api.nvim_buf_call(buf, function() + api.nvim_cmd({ + cmd = 'edit', + args = { source }, + mods = { noautocmd = true, noswapfile = true }, + magic = { file = false, bar = false }, + }, {}) + end) +end + +---@param command string +---@param source string +---@param path string +---@param target string +---@return string? +local function extract_path(command, source, path, target) + local file, err = io.open(target, 'wb') + if not file then + return err + end + local write_err ---@type string? + local ok, system = pcall( + vim.system, + { command, '-p', '--', literal_pattern(source), literal_pattern(path) }, + { + stdout = function(pipe_err, data) + if pipe_err then + write_err = pipe_err + elseif data and not write_err then + local written, file_err = file:write(data) + if not written then + write_err = file_err + end + end + end, + } + ) + if not ok then + file:close() + return tostring(system) + end + local result = system:wait() + file:close() + if write_err then + return write_err + end + if result.code ~= 0 then + return vim.trim(result.stderr or '') + end +end + +---@param buf integer +local function set_readonly(buf) + if not api.nvim_buf_is_valid(buf) then + return + end + api.nvim_set_option_value('swapfile', false, { buf = buf }) + api.nvim_set_option_value('modified', false, { buf = buf }) + api.nvim_set_option_value('buftype', 'nowrite', { buf = buf }) + api.nvim_set_option_value('readonly', true, { buf = buf }) + api.nvim_set_option_value('modifiable', false, { buf = buf }) +end + +--- Read extracted bytes through Nvim's normal reader to preserve encoding, EOL, and binary behavior. +---@param buf integer Target archive entry buffer. +---@param temp string Temporary file containing the extracted bytes. +local function read_tempfile(buf, temp) + api.nvim_buf_call(buf, function() + local name = api.nvim_buf_get_name(buf) + api.nvim_set_option_value('swapfile', false, { buf = buf }) + api.nvim_cmd({ + cmd = 'file', + args = { temp }, + mods = { keepalt = true, silent = true }, + magic = { file = false, bar = false }, + }, {}) + api.nvim_cmd({ cmd = 'edit', bang = true, mods = { keepjumps = true, silent = true } }, {}) + api.nvim_cmd({ + cmd = 'file', + args = { name }, + mods = { keepalt = true, silent = true }, + magic = { file = false, bar = false }, + }, {}) + api.nvim_cmd({ cmd = 'filetype', args = { 'detect' } }, {}) + end) + set_readonly(buf) +end + +--- Parse a `zipfile://` buffer name, as used by quickfix and direct `:edit`. +---@param name string `zipfile://{archive}::{path}` +---@return string?, string? archive and entry path +local function parse_uri(name) + if not vim.startswith(name, 'zipfile://') then + return + end + local value = name:sub(11) + local separator ---@type integer? + local offset = 1 + while true do + local next_separator = value:find('::', offset, true) + if not next_separator then + break + end + separator = next_separator + offset = next_separator + 2 + 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 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. + +---@param buf integer +---@return nvim.zip.State? +local function get_state(buf) + return vim.b[buf].nvim_zip +end + +---@param buf integer +---@param state nvim.zip.State +local function set_state(buf, state) + vim.b[buf].nvim_zip = state +end + +--- Open a local archive as a read-only `nvim.dir` listing. +---@param buf integer Target archive buffer. +---@param source string Expanded path to the archive. +function M.browse(buf, source) + buf = vim._resolve_bufnr(buf) + if vim.b[buf].nvim_dir ~= nil and get_state(buf) ~= nil then + return + end + local magic, magic_err = has_magic(source) + if magic == nil then + notify('zip', ('File not readable <%s>: %s'):format(source, magic_err)) + return + end + if not magic then + read_normally(buf, source) + return + end + local paths, err, fallback = list_archive(source) + if not paths then + if err then + notify( + 'zip', + fallback and ('%s is not a zip file: %s'):format(source, err) or err, + fallback and vim.log.levels.WARN or vim.log.levels.ERROR + ) + end + if fallback then + read_normally(buf, source) + end + return + end + set_state(buf, { source = source, paths = paths, prefix = '' }) + local name = vim.fn.bufname(buf) + require('nvim.dir').open(buf, name ~= '' and name or source, M) +end + +--- Read one archive entry into a read-only buffer. +---@param buf integer Target entry buffer. +---@param name string `zipfile://` 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) + end + if not source or not path then + set_readonly(buf) + notify('zip', ('could not parse buffer name %q'):format(name)) + return + end + local command, command_err = unzip() + if not command then + set_readonly(buf) + notify('zip', command_err or 'unzip executable not found') + return + end + local temp = vim.fn.tempname() + local err = extract_path(command, source, path, temp) + if err then + vim.fn.delete(temp) + set_readonly(buf) + notify('zip', ('unable to read %s from %s: %s'):format(path, source, err)) + return + end + local ok, read_err = pcall(read_tempfile, buf, temp) + vim.fn.delete(temp) + if not ok then + set_readonly(buf) + notify('zip', tostring(read_err)) + end +end + +--- List the requested archive level and commit its prefix only after the backend succeeds. +---@param buf integer +---@param _ string +---@param cb fun(err?: string, entries?: nvim.dir.Entry[]) +function M.list(buf, _, cb) + local state = get_state(buf) + if not state then + cb('zip source is not set') + return + end + local paths = state.paths + state.paths = nil + local err ---@type string? + if not paths then + paths, err = list_archive(state.source) + end + if not paths then + state.pending_prefix = nil + set_state(buf, state) + cb(err) + return + end + state.prefix = state.pending_prefix or state.prefix or '' + state.pending_prefix = nil + set_state(buf, state) + cb(nil, entries_at(paths, state.prefix)) +end + +---@param buf integer +---@param name string +---@param entry nvim.dir.Entry +function M.open(buf, name, entry) + local state = get_state(buf) + if not state then + return + end + local path = (state.prefix or '') .. entry.name .. (entry.dir and '/' or '') + if entry.dir then + state.pending_prefix = path + set_state(buf, state) + require('nvim.dir').open(buf, name, M) + return + end + local uri = ('zipfile://%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({ + cmd = 'edit', + args = { uri }, + mods = { noswapfile = true }, + magic = { file = false, bar = false }, + }, {}) +end + +---@param buf integer +---@param name string +function M.open_parent(buf, name) + local state = get_state(buf) + if not state then + return + end + local prefix = state.prefix or '' + if prefix ~= '' then + local path = prefix:sub(1, -2) + local child = assert(path:match('([^/]+)$')) + state.pending_prefix = path:match('^(.*[/])') or '' + set_state(buf, state) + require('nvim.dir').open(buf, name, M, { name = child, dir = true }) + return + end + if name:match('^%a[%w+.-]*://') then + return + end + require('nvim.dir.fs').open_parent_path(state.source) +end + +---@param buf integer +function M.init(buf) + api.nvim_set_option_value('filetype', 'zip', { buf = buf }) + api.nvim_buf_call(buf, function() + vim.wo.wrap = false + end) +end + +return M diff --git a/runtime/lua/vim/_core/util.lua b/runtime/lua/vim/_core/util.lua index adc5ce9253..bdfb4cc03a 100644 --- a/runtime/lua/vim/_core/util.lua +++ b/runtime/lua/vim/_core/util.lua @@ -184,6 +184,18 @@ function M.echo_err(msg) vim.api.nvim_echo({ { msg } }, true, { err = true }) end +--- Shows a message from a builtin plugin, prefixed with the plugin name. +--- +--- Scheduled, so it is safe to call from |api-fast| contexts. +--- @param name string Plugin name, e.g. "zip". +--- @param msg string +--- @param level? integer Level from |vim.log.levels|. Defaults to ERROR. +function M.notify(name, msg, level) + vim.schedule(function() + vim.notify(('%s: %s'):format(name, msg), level or vim.log.levels.ERROR) + end) +end + --- Define event-handlers (autocmds) ergonomically. --- --- Examples: diff --git a/runtime/pack/dist/opt/netrw/autoload/netrw.vim b/runtime/pack/dist/opt/netrw/autoload/netrw.vim index fb6db4967e..cabcd0c925 100644 --- a/runtime/pack/dist/opt/netrw/autoload/netrw.vim +++ b/runtime/pack/dist/opt/netrw/autoload/netrw.vim @@ -2278,7 +2278,12 @@ function s:NetrwGetFile(readcmd, tfile, method) " edit temporary file (ie. read the temporary file in) if rfile =~ '\.zip$' - call zip#Browse(tfile) + if exists("#zip") + call zip#Browse(tfile) + elseif exists("#nvim.zip") + call s:NetrwBufRename(rfile) + call luaeval("require('nvim.zip').browse(_A[1], _A[2])", [bufnr(), tfile]) + endif elseif rfile =~ '\.tar$' call tar#Browse(tfile) elseif rfile =~ '\.tar\.gz$' diff --git a/runtime/autoload/zip.vim b/runtime/pack/dist/opt/old-zip/autoload/zip.vim similarity index 100% rename from runtime/autoload/zip.vim rename to runtime/pack/dist/opt/old-zip/autoload/zip.vim diff --git a/runtime/doc/pi_zip.txt b/runtime/pack/dist/opt/old-zip/doc/pi_zip.txt similarity index 100% rename from runtime/doc/pi_zip.txt rename to runtime/pack/dist/opt/old-zip/doc/pi_zip.txt diff --git a/runtime/plugin/zipPlugin.vim b/runtime/pack/dist/opt/old-zip/plugin/zipPlugin.vim similarity index 100% rename from runtime/plugin/zipPlugin.vim rename to runtime/pack/dist/opt/old-zip/plugin/zipPlugin.vim diff --git a/runtime/plugin/net.lua b/runtime/plugin/net.lua index 1c7f04282c..a7002b8ac3 100644 --- a/runtime/plugin/net.lua +++ b/runtime/plugin/net.lua @@ -61,7 +61,11 @@ vim.api.nvim_create_autocmd('BufReadCmd', { end if archive_kind == 'zip' then - vim.fn['zip#Browse'](tmpfile) + if vim.fn.exists('#zip') == 1 then + vim.fn['zip#Browse'](tmpfile) + elseif vim.fn.exists('#nvim.zip') == 1 then + require('nvim.zip').browse(ev.buf, tmpfile) + end else vim.fn['tar#Browse'](tmpfile) end diff --git a/runtime/plugin/zip.lua b/runtime/plugin/zip.lua new file mode 100644 index 0000000000..b43de878e2 --- /dev/null +++ b/runtime/plugin/zip.lua @@ -0,0 +1,108 @@ +if vim.g.loaded_nvim_zip_plugin ~= nil or vim.fn.exists('#zip') == 1 then + return +end +vim.g.loaded_nvim_zip_plugin = true + +local api = vim.api + +-- Extension aliases inherited from the legacy plugin: +-- https://github.com/neovim/neovim/blob/7ba955fe079d4aa2554fea8e7235651fafd40efb/runtime/plugin/zipPlugin.vim#L34-L35 +---@type string[] +local extensions = { + 'aar', + 'apk', + 'cbz', + 'celzip', + 'crtx', + 'docm', + 'docx', + 'dotm', + 'dotx', + 'ear', + 'epub', + 'gcsx', + 'glox', + 'gqsx', + 'ja', + 'jar', + 'kmz', + 'odb', + 'odc', + 'odf', + 'odg', + 'odi', + 'odm', + 'odp', + 'ods', + 'odt', + 'otc', + 'otf', + 'otg', + 'oth', + 'oti', + 'otp', + 'ots', + 'ott', + 'oxt', + 'pkpass', + 'potm', + 'potx', + 'ppam', + 'ppsm', + 'ppsx', + 'pptm', + 'pptx', + 'sldx', + 'thmx', + 'vdw', + 'war', + 'whl', + 'wsz', + 'xap', + 'xlam', + 'xlsb', + 'xlsm', + 'xlsx', + 'xltm', + 'xltx', + 'xpi', + 'zip', +} +---@type string[] +local archive_patterns = {} +for _, extension in ipairs(extensions) do + archive_patterns[#archive_patterns + 1] = ('*.%s'):format(extension) +end +local group = api.nvim_create_augroup('nvim.zip', { clear = true }) + +---@return boolean +local function legacy_loaded() + return vim.fn.exists('#zip') == 1 +end + +api.nvim_create_autocmd('BufReadCmd', { + group = group, + pattern = 'zipfile://*', + desc = 'Read zip archive entry', + callback = function(ev) + if legacy_loaded() then + return true + end + require('nvim.zip').read(ev.buf, ev.match) + end, +}) + +api.nvim_create_autocmd('BufReadCmd', { + group = group, + pattern = archive_patterns, + desc = 'Browse zip archives', + callback = function(ev) + if legacy_loaded() then + return true + end + if ev.match:match('^%a[%w+.-]*://') then + return + end + require('nvim.zip').browse(ev.buf, ev.match) + end, +}) diff --git a/runtime/syntax/zip.lua b/runtime/syntax/zip.lua new file mode 100644 index 0000000000..784f9725c6 --- /dev/null +++ b/runtime/syntax/zip.lua @@ -0,0 +1,12 @@ +-- Syntax highlighting for nvim.zip archive listings. + +if vim.b.current_syntax then + return +end + +vim.cmd [[ + syntax match zipDirectory '.*/$' + highlight default link zipDirectory Directory +]] + +vim.b.current_syntax = 'zip' diff --git a/test/functional/fixtures/zip/browser.zip b/test/functional/fixtures/zip/browser.zip new file mode 100644 index 0000000000000000000000000000000000000000..24e271642219e06a04dbc3ec23ab9fb0e501e2de GIT binary patch literal 1143 zcmWIWW@Zs#fB;1Z1tr-T7#Py>b5c@^^l>ZVLnz8iEiOq-K~tN;IK_&Gfq?;p#W2*S zW#**nl~j~Kv?`ROR+Mm|+SMwXRLaG`zyQMh2>nI*`6VEYAR3#t7>PDPka7^_WME*( z%*#tH(yPiWfZ9}$SecWbn1W{6&u@1fK{`N~mw|ynPftI!I61KZ>_!Mn0lUsa%e0pZ zGB7ZJFi590|NsHgZ!44pPHWo(vX+0kd~R3n4^%FTAq`cmx>6<1#RDB%KGD_K(PhFAg$1- z1%;qqR$^IVa!z7#u>y#zP+iLv;LXS+!i+m%K+=c+!(T@b3u+cVO(1JQN!^x| zN=S_8qw7UaI1s%I3=NEd7b=XuHh_ dFEYTv3QAW2-mGjO6qO4B|{69so2{&D8(^ literal 0 HcmV?d00001 diff --git a/test/functional/lua/net_spec.lua b/test/functional/lua/net_spec.lua index 6e9fc88cc9..2774004c60 100644 --- a/test/functional/lua/net_spec.lua +++ b/test/functional/lua/net_spec.lua @@ -164,29 +164,60 @@ describe('vim.net.request', function() t.eq(true, rv.tarfile) end) - it('opens remote zip URLs as zip archives', function() + it('dispatches remote zip URLs to zip.lua', function() + n.clear({ args_rm = { '-u' } }) + local fixture = + vim.fs.joinpath(t.paths.test_source_path, 'test/functional/fixtures/zip/browser.zip') + local rv = exec_lua(function(path) + vim.net.request = function(_, opts, callback) + assert(vim.uv.fs_copyfile(path, opts.outpath)) + callback(nil) + end + vim.cmd.edit('https://example.com/browser.zip') + vim.wait(1000, function() + return vim.b.nvim_zip ~= nil + end) + require('nvim.zip').open_parent(0, vim.api.nvim_buf_get_name(0)) + return { + legacy = vim.g.loaded_zip ~= nil, + name = vim.api.nvim_buf_get_name(0), + nvim_zip = vim.b.nvim_zip ~= nil, + } + end, fixture) + + t.eq(false, rv.legacy) + t.eq('https://example.com/browser.zip', rv.name) + t.eq(true, rv.nvim_zip) + end) + + it('downloads a live remote zip URL', function() t.skip(skip_integ, 'NVIM_TEST_INTEG not set (network integration test)') + n.clear({ args = { '--clean' } }) local rv = exec_lua(function() - vim.cmd('runtime! plugin/net.lua') - vim.cmd('runtime! plugin/zipPlugin.vim') - vim.cmd('edit https://github.com/neovim/neovim/releases/download/nightly/nvim-win-arm64.zip') vim.wait(2500, function() return vim.bo.filetype == 'zip' or vim.b.zipfile ~= nil end) + require('nvim.zip').open_parent(0, vim.api.nvim_buf_get_name(0)) return { filetype = vim.bo.filetype, + legacy = vim.g.loaded_zip ~= nil, modified = vim.bo.modified, + name = vim.api.nvim_buf_get_name(0), + nvim_zip = vim.b.nvim_zip ~= nil, zipfile = vim.b.zipfile ~= nil, } end) t.eq('zip', rv.filetype) + t.eq(false, rv.legacy) t.eq(false, rv.modified) - t.eq(true, rv.zipfile) + t.eq('https://github.com/neovim/neovim/releases/download/nightly/nvim-win-arm64.zip', rv.name) + t.eq(true, rv.nvim_zip) + t.eq(false, rv.zipfile) end) it('accepts custom headers', function() diff --git a/test/functional/plugin/zip_spec.lua b/test/functional/plugin/zip_spec.lua new file mode 100644 index 0000000000..7d5497710a --- /dev/null +++ b/test/functional/plugin/zip_spec.lua @@ -0,0 +1,352 @@ +local n = require('test.functional.testnvim')() +local t = require('test.testutil') + +local describe, it, before_each, after_each = t.describe, t.it, t.before_each, t.after_each +local api = n.api +local eq = t.eq +local exec_capture = n.exec_capture +local exec_lua = n.exec_lua +local feed = n.feed +local fn = n.fn +local poke_eventloop = n.poke_eventloop + +local fixtures = vim.fs.joinpath(t.paths.test_source_path, 'test/functional/fixtures/zip') +local old_samples = vim.fs.joinpath(t.paths.test_source_path, 'test/old/testdir/samples') + +local function lines() + return api.nvim_buf_get_lines(0, 0, -1, false) +end + +local function edit(path) + api.nvim_cmd({ cmd = 'edit', args = { path }, magic = { file = false, bar = false } }, {}) +end + +local function clear_zip() + n.clear({ args = { '--clean' } }) +end + +local function copy_fixture(source, target) + assert(vim.uv.fs_copyfile(source, target)) +end + +local function line_of(text) + for i, line in ipairs(lines()) do + if line == text then + return i + end + end + error(('missing line %q in %s'):format(text, vim.inspect(lines()))) +end + +describe('nvim.zip', function() + local root + + before_each(function() + t.skip(vim.fn.executable('unzip') == 0, 'unzip not available') + root = vim.fs.normalize(t.tmpname(false) .. ' space%#') + t.mkdir(root) + end) + + after_each(function() + n.rmdir(root) + end) + + it('uses zip.lua by default', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + + eq('folder/', lines()[1]) + eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) + eq(false, exec_lua('return vim.g.loaded_zipPlugin ~= nil')) + end) + + it('defers to zipPlugin.vim loaded before startup plugins', function() + local archive = vim.fs.joinpath(root, 'legacy.zip') + copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), archive) + n.clear({ args = { '--clean', '--cmd', 'packadd old-zip' } }) + + edit(archive) + + eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) + eq(0, fn.exists('#nvim.zip')) + eq(false, exec_lua('return vim.g.loaded_nvim_zip_plugin ~= nil')) + end) + + it('yields to zipPlugin.vim loaded after startup', function() + local archive = vim.fs.joinpath(root, 'legacy.zip') + copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), archive) + clear_zip() + exec_lua([[vim.cmd.packadd('old-zip')]]) + + edit(archive) + + eq(true, lines()[1]:find('" zip.vim version', 1, true) ~= nil) + eq(1, fn.exists('#nvim.zip')) + eq(true, exec_lua('return vim.g.loaded_nvim_zip_plugin == true')) + end) + + it('can be disabled', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + n.clear({ + args = { '--clean', '--cmd', 'let g:loaded_nvim_zip_plugin = 1' }, + }) + + edit(archive) + + eq(0, fn.exists('#nvim.zip')) + eq('', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('can source zip.lua repeatedly', function() + clear_zip() + + eq( + 2, + exec_lua(function() + vim.cmd.runtime('plugin/zip.lua') + vim.cmd.runtime('plugin/zip.lua') + local ids = {} + for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ group = 'nvim.zip' })) do + ids[autocmd.id] = true + end + return vim.tbl_count(ids) + end) + ) + end) + + it('opens zip-compatible file types', function() + local archive = vim.fs.joinpath(root, 'browser.jar') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + + eq('folder/', lines()[1]) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('browses directories and opens entries', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + + eq( + { 'folder/', 'inner.zip', '../escape.txt', '/absolute.txt', 'crlf.txt', 'noeol.txt' }, + lines() + ) + eq('zip', api.nvim_get_option_value('filetype', { buf = 0 })) + eq(true, exec_capture('syntax list zipDirectory'):find('zipDirectory', 1, true) ~= nil) + + feed('') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + feed('') + poke_eventloop() + eq({ 'file.txt' }, lines()) + + feed('-') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + eq('nested/', api.nvim_get_current_line()) + + api.nvim_win_set_cursor(0, { 2, 0 }) + feed('') + poke_eventloop() + eq({ 'root text' }, lines()) + eq('nowrite', api.nvim_get_option_value('buftype', { buf = 0 })) + eq(true, api.nvim_get_option_value('readonly', { buf = 0 })) + eq(false, api.nvim_get_option_value('modifiable', { buf = 0 })) + eq(false, api.nvim_get_option_value('swapfile', { buf = 0 })) + end) + + it('opens the containing directory from the archive root', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + feed('-') + poke_eventloop() + + eq(root, vim.fs.normalize(api.nvim_buf_get_name(0))) + eq('browser.zip', api.nvim_get_current_line()) + eq('directory', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('opens entries at quickfix locations', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + local uri = ('zipfile://%s::crlf.txt'):format(archive) + fn.setqflist({}, 'r', { + items = { { filename = uri, lnum = 2, col = 1 } }, + }) + + api.nvim_cmd({ cmd = 'cfirst' }, {}) + + eq(uri, api.nvim_buf_get_name(0)) + eq({ 2, 0 }, api.nvim_win_get_cursor(0)) + eq('text', api.nvim_get_option_value('filetype', { buf = 0 })) + end) + + it('does not reinterpret an entry ending in .zip as an archive', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) + feed('') + poke_eventloop() + + eq({ 'nested payload' }, lines()) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) + + it('preserves normal file reading details for entries', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(('zipfile://%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)) + eq({ 'no final newline' }, lines()) + eq(false, api.nvim_get_option_value('endofline', { buf = 0 })) + end) + + it('integrates Java sources with zip.lua and zipPlugin.vim', function() + local archive = vim.fs.joinpath(root, 'source.jar') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + + for _, legacy in ipairs({ false, true }) do + if legacy then + n.clear({ + args = { '--clean', '--cmd', 'packadd old-zip' }, + }) + else + clear_zip() + end + exec_lua('vim.g.ftplugin_java_source_path = ...', archive) + api.nvim_buf_set_name(0, vim.fs.joinpath(root, 'Test.java')) + api.nvim_buf_set_lines(0, 0, -1, false, { 'folder.root' }) + api.nvim_cmd({ cmd = 'setfiletype', args = { 'java' } }, {}) + api.nvim_cmd({ cmd = 'runtime', args = { 'ftplugin/java.vim' } }, {}) + + feed('gf') + poke_eventloop() + + eq(('zipfile://%s::folder/root.java'):format(archive), api.nvim_buf_get_name(0)) + eq({ 'class root {}' }, lines()) + eq('java', api.nvim_get_option_value('filetype', { buf = 0 })) + end + end) + + it('reads entry selectors literally', function() + t.skip(t.is_os('win'), 'N/A: archive contains backslashes in entry paths') + local archive = vim.fs.joinpath(root, 'special.zip') + copy_fixture(vim.fs.joinpath(old_samples, 'testa.zip'), archive) + clear_zip() + + local cases = { + { 'zipglob/a[a].txt', 'a test file with []' }, + { 'zipglob/a*.txt', 'a test file with a*' }, + { 'zipglob/a?.txt', 'a test file with a?' }, + { [[zipglob/a\.txt]], [[a test file with a\]] }, + { [[zipglob/a\\.txt]], [[a test file with a double \]] }, + } + for _, case in ipairs(cases) do + edit(('zipfile://%s::%s'):format(archive, case[1])) + eq({ case[2] }, lines()) + end + end) + + it('treats archive glob characters literally', function() + t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters') + local archive = vim.fs.joinpath(root, 'archive::[*?].zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + copy_fixture(vim.fs.joinpath(old_samples, 'test.zip'), vim.fs.joinpath(root, 'archivex.zip')) + clear_zip() + + edit(archive) + + eq('folder/', lines()[1]) + api.nvim_win_set_cursor(0, { line_of('inner.zip'), 0 }) + feed('') + poke_eventloop() + eq({ 'nested payload' }, lines()) + + local uri = ('zipfile://%s::crlf.txt'):format(archive) + edit(uri) + eq(uri, api.nvim_buf_get_name(0)) + eq({ 'one', 'two' }, lines()) + end) + + it('keeps suspicious entry paths visible and readable', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + api.nvim_win_set_cursor(0, { line_of('../escape.txt'), 0 }) + feed('') + poke_eventloop() + + eq({ 'escape payload' }, lines()) + end) + + it('opens non-zip files normally', function() + local archive = vim.fs.joinpath(root, 'plain.zip') + t.write_file(archive, 'plain text', true) + clear_zip() + + edit(archive) + + eq({ 'plain text' }, lines()) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) + + it('keeps the current level when reload fails', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + + edit(archive) + feed('') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + assert(os.remove(archive)) + feed('R') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + feed('R') + poke_eventloop() + eq({ 'nested/', 'root.txt', 'root.java' }, lines()) + end) + + it('reports an unavailable backend without claiming the buffer', function() + local archive = vim.fs.joinpath(root, 'browser.zip') + copy_fixture(vim.fs.joinpath(fixtures, 'browser.zip'), archive) + clear_zip() + exec_lua([[vim.env.PATH = '']]) + + edit(archive) + poke_eventloop() + + eq(true, exec_capture('messages'):find('unzip executable not found', 1, true) ~= nil) + eq(false, exec_lua('return vim.b.nvim_dir ~= nil')) + end) +end) diff --git a/test/old/testdir/test_plugin_zip.vim b/test/old/testdir/test_plugin_zip.vim index 82dc741b38..ae645c7063 100644 --- a/test/old/testdir/test_plugin_zip.vim +++ b/test/old/testdir/test_plugin_zip.vim @@ -7,7 +7,7 @@ if 0 " Find uncovered line profile! file */zip*.vim endif -runtime plugin/zipPlugin.vim +packadd old-zip func s:CopyZipFile(source) if !filecopy($"samples/{a:source}", "X.zip")