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*
*vim.net.request.Response*
Fields: ~
• {body} (`string`) The HTTP body of the request
vim.net.request({url}, {opts}, {on_response}) *vim.net.request()*
Makes an HTTP GET request to the given URL (asynchronous).
This function operates in one mode:
• Asynchronous (non-blocking): Returns immediately and passes the response
object to the provided `on_response` handler on completion.
This function is asynchronous (non-blocking), returning immediately and
passing the response object to the optional `on_response` handler on
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: ~
• {url} (`string`) The URL for the request.
• {opts} (`table?`) Optional parameters:
`verbose` (boolean|nil): Enables verbose output.
`retry` (integer|nil): Number of retries on transient
• {opts} (`table?`) A table with the following fields:
{verbose}? (`boolean`) Enables verbose output.
{retry}? (`integer`) Number of retries on transient
failures (default: 3).
`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.
• {on_response} (`fun(err?: string, response?: { body: string|boolean })`)
{outpath}? (`string`) File path to save the response
body to.
• {outbuf}? (`integer`) Buffer to save the response
body to.
• {on_response} (`fun(err: string?, response: vim.net.request.Response?)?`)
Callback invoked on request 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.
field in the response parameter contains the raw
response data (text or binary).
Return: ~
(`{ close: fun() }`) Table with method to cancel, similar to
|vim.SystemObj|.
==============================================================================

View File

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

View File

@@ -1,23 +1,67 @@
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).
---
--- This function operates in one mode:
--- - Asynchronous (non-blocking): Returns immediately and passes the response object to the
--- provided `on_response` handler on completion.
--- This function is asynchronous (non-blocking), returning immediately and
--- passing the response object to the optional `on_response` handler on
--- 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 opts? table Optional parameters:
--- - `verbose` (boolean|nil): Enables verbose output.
--- - `retry` (integer|nil): Number of retries on transient failures (default: 3).
--- - `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.
--- @param on_response fun(err?: string, response?: { body: string|boolean }) Callback invoked on request
--- 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.
--- @param opts? vim.net.request.Opts
--- @param on_response? fun(err: string?, response: vim.net.request.Response?)
--- Callback invoked on request completion. The `body` field in the response
--- parameter contains the raw response data (text or binary).
--- @return { close: fun() } # Table with method to cancel, similar to [vim.SystemObj].
function M.request(url, opts, on_response)
vim.validate('url', url, 'string')
vim.validate('opts', opts, 'table', true)
vim.validate('on_response', on_response, 'function')
vim.validate('on_response', on_response, 'function', true)
opts = opts or {}
local retry = opts.retry or 3
@@ -37,19 +81,39 @@ function M.request(url, opts, on_response)
table.insert(args, url)
local function on_exit(res)
local s = 'Request failed with exit code %d'
local err_msg = res.code ~= 0
and ((res.stderr ~= '' and res.stderr) or string.format(s, res.code))
or nil
local response = res.code == 0 and { body = opts.outpath and true or res.stdout } or nil
local job = vim.system(args, {}, function(res)
---@type string?, vim.net.request.Response?
local err, response = nil, nil
if res.signal ~= 0 then
err = ('Request killed with signal %d'):format(res.signal)
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
on_response(err_msg, response)
on_response(err, response)
end
end
end)
vim.system(args, {}, on_exit)
return {
close = function()
job:kill('sigint')
end,
}
end
return M

View File

@@ -39,7 +39,7 @@ augroup Network
au!
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 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 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

View File

@@ -1,45 +1,70 @@
vim.g.loaded_remote_file_loader = true
--- 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)
)
if vim.g.loaded_nvim_net_plugin ~= nil then
return
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', {
group = vim.api.nvim_create_augroup('nvim.net.remotefile', {}),
pattern = { 'http://*', 'https://*' },
group = augroup,
pattern = url_patterns,
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 done = false
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)
result = body.body
done = true
@@ -63,9 +62,8 @@ describe('vim.net.request', function()
local err = exec_lua([[
local done = false
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
done = true
end)
@@ -76,4 +74,43 @@ describe('vim.net.request', function()
assert_404_error(err)
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)