feat(net): vim.net.request(outbuf) writes response to buffer #36164

Problem:
Non-trivial to write output of vim.net.request to buffer. Requires extra
code in plugin/net.lua which can't be reused by other plugin authors.

```
vim.net.request('https://neovim.io', {}, function(err, res)
  if not err then
    local buf = vim.api.nvim_create_buf(true, false)
    if res then
      local lines = vim.split(res.body, '\n', { plain = true })
      vim.api.nvim_buf_set_lines(buf, 0, -1, true, lines)
    end
  end
end)
```

Solution:
Accept an optional `outbuf` argument to indicate the buffer to write output
to, similar to `outpath`.

    vim.net.request('https://neovim.io', { outbuf = buf })

Other fixes / followups:
- Make plugin/net.lua smaller
- Return objection with close() method
- vim.net.request.Opts class
- vim.validate single calls
- Use (''):format(...) instead of `..`
This commit is contained in:
Yochem van Rosmalen
2026-03-23 23:48:03 +01:00
committed by GitHub
parent 7c3df3e2ea
commit f29b3b5d45
6 changed files with 244 additions and 80 deletions

View File

@@ -4149,29 +4149,62 @@ vim.mpack.encode({obj}) *vim.mpack.encode()*
============================================================================== ==============================================================================
Lua module: vim.net *vim.net* Lua module: vim.net *vim.net*
*vim.net.request.Response*
Fields: ~
• {body} (`string`) The HTTP body of the request
vim.net.request({url}, {opts}, {on_response}) *vim.net.request()* vim.net.request({url}, {opts}, {on_response}) *vim.net.request()*
Makes an HTTP GET request to the given URL (asynchronous). Makes an HTTP GET request to the given URL (asynchronous).
This function operates in one mode: This function is asynchronous (non-blocking), returning immediately and
• Asynchronous (non-blocking): Returns immediately and passes the response passing the response object to the optional `on_response` handler on
object to the provided `on_response` handler on completion. completion.
Examples: >lua
-- Write response body to file
vim.net.request('https://neovim.io/charter/', {
outpath = 'vision.html',
})
-- Process the response
vim.net.request(
'https://api.github.com/repos/neovim/neovim',
{},
function (err, res)
if err then return end
local stars = vim.json.decode(res.body).stargazers_count
vim.print(('Neovim currently has %d stars'):format(stars))
end
)
-- Write to both file and current buffer, but cancel it
local job = vim.net.request('https://neovim.io/charter/', {
outpath = 'vision.html',
outbuf = 0,
})
job:close()
<
Parameters: ~ Parameters: ~
• {url} (`string`) The URL for the request. • {url} (`string`) The URL for the request.
• {opts} (`table?`) Optional parameters: • {opts} (`table?`) A table with the following fields:
`verbose` (boolean|nil): Enables verbose output. {verbose}? (`boolean`) Enables verbose output.
`retry` (integer|nil): Number of retries on transient {retry}? (`integer`) Number of retries on transient
failures (default: 3). failures (default: 3).
`outpath` (string|nil): File path to save the {outpath}? (`string`) File path to save the response
response body to. If set, the `body` value in the body to.
Response Object will be `true` instead of the • {outbuf}? (`integer`) Buffer to save the response
response body. body to.
• {on_response} (`fun(err?: string, response?: { body: string|boolean })`) • {on_response} (`fun(err: string?, response: vim.net.request.Response?)?`)
Callback invoked on request completion. The `body` Callback invoked on request completion. The `body`
field in the response object contains the raw response field in the response parameter contains the raw
data (text or binary). Called with (err, nil) on response data (text or binary).
failure, or (nil, { body = string|boolean }) on
success. Return: ~
(`{ close: fun() }`) Table with method to cancel, similar to
|vim.SystemObj|.
============================================================================== ==============================================================================

View File

@@ -26,6 +26,11 @@ EXPERIMENTS
• todo • todo
LUA
• |vim.net.request()| does not set the response body to `true` if an output
buffer is supplied.
LSP LSP
• |vim.lsp.buf.selection_range()| now accepts an integer which specifies its • |vim.lsp.buf.selection_range()| now accepts an integer which specifies its

View File

@@ -1,23 +1,67 @@
local M = {} local M = {}
---@class vim.net.request.Opts
---@inlinedoc
---
---Enables verbose output.
---@field verbose? boolean
---
---Number of retries on transient failures (default: 3).
---@field retry? integer
---
---File path to save the response body to.
---@field outpath? string
---
---Buffer to save the response body to.
---@field outbuf? integer
---@class vim.net.request.Response
---
---The HTTP body of the request
---@field body string
--- Makes an HTTP GET request to the given URL (asynchronous). --- Makes an HTTP GET request to the given URL (asynchronous).
--- ---
--- This function operates in one mode: --- This function is asynchronous (non-blocking), returning immediately and
--- - Asynchronous (non-blocking): Returns immediately and passes the response object to the --- passing the response object to the optional `on_response` handler on
--- provided `on_response` handler on completion. --- completion.
---
--- Examples:
--- ```lua
--- -- Write response body to file
--- vim.net.request('https://neovim.io/charter/', {
--- outpath = 'vision.html',
--- })
---
--- -- Process the response
--- vim.net.request(
--- 'https://api.github.com/repos/neovim/neovim',
--- {},
--- function (err, res)
--- if err then return end
--- local stars = vim.json.decode(res.body).stargazers_count
--- vim.print(('Neovim currently has %d stars'):format(stars))
--- end
--- )
---
--- -- Write to both file and current buffer, but cancel it
--- local job = vim.net.request('https://neovim.io/charter/', {
--- outpath = 'vision.html',
--- outbuf = 0,
--- })
--- job:close()
--- ```
--- ---
--- @param url string The URL for the request. --- @param url string The URL for the request.
--- @param opts? table Optional parameters: --- @param opts? vim.net.request.Opts
--- - `verbose` (boolean|nil): Enables verbose output. --- @param on_response? fun(err: string?, response: vim.net.request.Response?)
--- - `retry` (integer|nil): Number of retries on transient failures (default: 3). --- Callback invoked on request completion. The `body` field in the response
--- - `outpath` (string|nil): File path to save the response body to. If set, the `body` value in the Response Object will be `true` instead of the response body. --- parameter contains the raw response data (text or binary).
--- @param on_response fun(err?: string, response?: { body: string|boolean }) Callback invoked on request --- @return { close: fun() } # Table with method to cancel, similar to [vim.SystemObj].
--- completion. The `body` field in the response object contains the raw response data (text or binary).
--- Called with (err, nil) on failure, or (nil, { body = string|boolean }) on success.
function M.request(url, opts, on_response) function M.request(url, opts, on_response)
vim.validate('url', url, 'string') vim.validate('url', url, 'string')
vim.validate('opts', opts, 'table', true) vim.validate('opts', opts, 'table', true)
vim.validate('on_response', on_response, 'function') vim.validate('on_response', on_response, 'function', true)
opts = opts or {} opts = opts or {}
local retry = opts.retry or 3 local retry = opts.retry or 3
@@ -37,19 +81,39 @@ function M.request(url, opts, on_response)
table.insert(args, url) table.insert(args, url)
local function on_exit(res) local job = vim.system(args, {}, function(res)
local s = 'Request failed with exit code %d' ---@type string?, vim.net.request.Response?
local err_msg = res.code ~= 0 local err, response = nil, nil
and ((res.stderr ~= '' and res.stderr) or string.format(s, res.code)) if res.signal ~= 0 then
or nil err = ('Request killed with signal %d'):format(res.signal)
local response = res.code == 0 and { body = opts.outpath and true or res.stdout } or nil elseif res.code ~= 0 then
err = res.stderr ~= '' and res.stderr or ('Request failed with exit code %d'):format(res.code)
else
if not (opts.outpath or opts.outbuf) then
response = {
body = res.stdout --[[@as string]],
}
end
end
-- nvim_buf_is_loaded and nvim_buf_set_lines are not allowed in fast context
vim.schedule(function()
if res.code == 0 and opts.outbuf and vim.api.nvim_buf_is_loaded(opts.outbuf) then
local lines = vim.split(res.stdout, '\n', { plain = true })
vim.api.nvim_buf_set_lines(opts.outbuf, 0, -1, true, lines)
end
end)
if on_response then if on_response then
on_response(err_msg, response) on_response(err, response)
end end
end end)
vim.system(args, {}, on_exit) return {
close = function()
job:kill('sigint')
end,
}
end end
return M return M

View File

@@ -39,7 +39,7 @@ augroup Network
au! au!
au BufReadCmd file://* call netrw#FileUrlEdit(expand("<amatch>")) au BufReadCmd file://* call netrw#FileUrlEdit(expand("<amatch>"))
au BufReadCmd ftp://*,rcp://*,scp://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufReadPre ".fnameescape(expand("<amatch>"))|call netrw#Nread(2,expand("<amatch>"))|exe "sil doau BufReadPost ".fnameescape(expand("<amatch>")) au BufReadCmd ftp://*,rcp://*,scp://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufReadPre ".fnameescape(expand("<amatch>"))|call netrw#Nread(2,expand("<amatch>"))|exe "sil doau BufReadPost ".fnameescape(expand("<amatch>"))
au FileReadCmd ftp://*,rcp://*,scp://*,http://*,file://*,https://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileReadPre ".fnameescape(expand("<amatch>"))|call netrw#Nread(1,expand("<amatch>"))|exe "sil doau FileReadPost ".fnameescape(expand("<amatch>")) au FileReadCmd ftp://*,rcp://*,scp://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileReadPre ".fnameescape(expand("<amatch>"))|call netrw#Nread(1,expand("<amatch>"))|exe "sil doau FileReadPost ".fnameescape(expand("<amatch>"))
au BufWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufWritePre ".fnameescape(expand("<amatch>"))|exe 'Nwrite '.fnameescape(expand("<amatch>"))|exe "sil doau BufWritePost ".fnameescape(expand("<amatch>")) au BufWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufWritePre ".fnameescape(expand("<amatch>"))|exe 'Nwrite '.fnameescape(expand("<amatch>"))|exe "sil doau BufWritePost ".fnameescape(expand("<amatch>"))
au FileWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileWritePre ".fnameescape(expand("<amatch>"))|exe "'[,']".'Nwrite '.fnameescape(expand("<amatch>"))|exe "sil doau FileWritePost ".fnameescape(expand("<amatch>")) au FileWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileWritePre ".fnameescape(expand("<amatch>"))|exe "'[,']".'Nwrite '.fnameescape(expand("<amatch>"))|exe "sil doau FileWritePost ".fnameescape(expand("<amatch>"))
try try

View File

@@ -1,45 +1,70 @@
vim.g.loaded_remote_file_loader = true if vim.g.loaded_nvim_net_plugin ~= nil then
return
--- Callback for BufReadCmd on remote URLs.
--- @param ev { buf: integer }
local function on_remote_read(ev)
if vim.fn.executable('curl') ~= 1 then
vim.api.nvim_echo({
{ 'Warning: `curl` not found; remote URL loading disabled.', 'WarningMsg' },
}, true, {})
return true
end
local bufnr = ev.buf
local url = vim.api.nvim_buf_get_name(bufnr)
local view = vim.fn.winsaveview()
vim.api.nvim_echo({ { 'Fetching ' .. url .. '', 'MoreMsg' } }, true, {})
vim.net.request(
url,
{ retry = 3 },
vim.schedule_wrap(function(err, content)
if err then
vim.notify('Failed to fetch ' .. url .. ': ' .. tostring(err), vim.log.levels.ERROR)
vim.fn.winrestview(view)
return
end
local lines = vim.split(content.body, '\n', { plain = true })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
vim.api.nvim_exec_autocmds('BufRead', { group = 'filetypedetect', buffer = bufnr })
vim.bo[bufnr].modified = false
vim.fn.winrestview(view)
vim.api.nvim_echo({ { 'Loaded ' .. url, 'Normal' } }, true, {})
end)
)
end end
vim.g.loaded_nvim_net_plugin = true
local augroup = vim.api.nvim_create_augroup('nvim.net.remotefile', {})
local url_patterns = { 'http://*', 'https://*' }
vim.api.nvim_create_autocmd('BufReadCmd', { vim.api.nvim_create_autocmd('BufReadCmd', {
group = vim.api.nvim_create_augroup('nvim.net.remotefile', {}), group = augroup,
pattern = { 'http://*', 'https://*' }, pattern = url_patterns,
desc = 'Edit remote files (:edit https://example.com)', desc = 'Edit remote files (:edit https://example.com)',
callback = on_remote_read, callback = function(ev)
if vim.fn.executable('curl') ~= 1 then
vim.notify('vim.net.request: curl not found', vim.log.levels.WARN)
return
end
local url = ev.file
vim.notify(('Fetching %s …'):format(url), vim.log.levels.INFO)
vim.net.request(
url,
{ outbuf = ev.buf },
vim.schedule_wrap(function(err, _)
if err then
vim.notify(('Failed to fetch %s: %s'):format(url, err), vim.log.levels.ERROR)
return
end
vim.api.nvim_exec_autocmds('BufRead', { group = 'filetypedetect', buffer = ev.buf })
vim.bo[ev.buf].modified = false
vim.notify(('Loaded %s'):format(url), vim.log.levels.INFO)
end)
)
end,
})
vim.api.nvim_create_autocmd('FileReadCmd', {
group = augroup,
pattern = url_patterns,
desc = 'Read remote files (:read https://example.com)',
callback = function(ev)
if vim.fn.executable('curl') ~= 1 then
vim.notify('vim.net.request: curl not found', vim.log.levels.WARN)
return
end
local url = ev.file
vim.notify(('Fetching %s …'):format(url), vim.log.levels.INFO)
vim.net.request(
url,
{},
vim.schedule_wrap(function(err, response)
if err or not response then
vim.notify(('Failed to fetch %s: %s'):format(url, err), vim.log.levels.ERROR)
return
end
-- Start inserting the response at the line number given by read (e.g. :10read).
-- FIXME: Doesn't work for :0read as '[ is set to 1. See #7177 for possible solutions.
local start = vim.fn.line("'[")
local lines = vim.split(response.body or '', '\n', { plain = true })
vim.api.nvim_buf_set_lines(ev.buf, start, start, true, lines)
vim.notify(('Loaded %s'):format(url), vim.log.levels.INFO)
end)
)
end,
}) })

View File

@@ -21,9 +21,8 @@ describe('vim.net.request', function()
local content = exec_lua([[ local content = exec_lua([[
local done = false local done = false
local result local result
local M = require('vim.net')
M.request("https://httpbingo.org/anything", { retry = 3 }, function(err, body) vim.net.request("https://httpbingo.org/anything", { retry = 3 }, function(err, body)
assert(not err, err) assert(not err, err)
result = body.body result = body.body
done = true done = true
@@ -63,9 +62,8 @@ describe('vim.net.request', function()
local err = exec_lua([[ local err = exec_lua([[
local done = false local done = false
local result local result
local M = require('vim.net')
M.request("https://httpbingo.org/status/404", {}, function(e, _) vim.net.request("https://httpbingo.org/status/404", {}, function(e, _)
result = e result = e
done = true done = true
end) end)
@@ -76,4 +74,43 @@ describe('vim.net.request', function()
assert_404_error(err) assert_404_error(err)
end) end)
it('plugin writes output to buffer', function()
t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test')
local content = exec_lua([[
local lines
local buf = vim.api.nvim_create_buf(false, true)
vim.net.request("https://httpbingo.org", { outbuf = buf })
vim.wait(2000, function()
lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
return lines[1] ~= ""
end)
return lines
]])
assert(content and content[1]:find('html'))
end)
it('works with :read', function()
t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test')
local content = exec_lua([[
vim.cmd('runtime plugin/net.lua')
local lines
vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'Here is some text' })
vim.cmd(':read https://example.com')
vim.wait(2000, function()
lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
return #lines > 1
end)
return lines
]])
t.eq(true, content ~= nil)
t.eq(true, content[1]:find('Here') ~= nil)
t.eq(true, content[2]:find('html') ~= nil)
end)
end) end)