Files
neovim/runtime/lua/nvim/zip.lua
Barrett Ruth e29af9c32f 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`.
2026-07-29 12:54:43 -04:00

400 lines
11 KiB
Lua

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<string,true>
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