feat(net): vim.net.request(), :edit [url] #34140

Problem:
Nvim depends on netrw to download/request URL contents.

Solution:
- Add `vim.net.request()` as a thin curl wrapper:
  - Basic GET with --silent, --show-error, --fail, --location, --retry
  - Optional `opts.outpath` to save to a file
  - Operates asynchronously. Pass an `on_response` handler to get the result.
- Add integ tests (requires NVIM_TEST_INTEG to be set) to test success
  and 404 failure.
- Health check for missing `curl`.
- Handle `:edit https://…` using `vim.net.request()`.

API Usage:
1. Asynchronous request:

    vim.net.request('https://httpbingo.org/get', { retry = 2 }, function(err, response)
      if err then
        print('Fetch failed:', err)
      else
        print('Got body of length:', #response.body)
      end
    end)

2. Download to file:

    vim.net.request('https://httpbingo.org/get', { outpath = 'out_async.txt' }, function(err)
      if err then print('Error:', err) end
    end)

3. Remote :edit integration (in runtime/plugin/net.lua) fetches into buffer:

    :edit https://httpbingo.org/get
This commit is contained in:
Tom Ampuero
2025-07-13 21:43:11 +01:00
committed by GitHub
parent 444a8b3ec6
commit 7cd5356a6f
10 changed files with 269 additions and 1 deletions

View File

@ -5043,4 +5043,32 @@ tohtml.tohtml({winid}, {opt}) *tohtml.tohtml.tohtml()*
(`string[]`) (`string[]`)
==============================================================================
Lua module: vim.net *vim.net*
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 completetion.
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
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 })`)
Callback invoked on request completetion. 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.
vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl: vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:

View File

@ -90,6 +90,7 @@ LSP
LUA LUA
• Renamed `vim.diff` to `vim.text.diff`. • Renamed `vim.diff` to `vim.text.diff`.
• |vim.net.request()| adds a minimal HTTP GET API using curl.
OPTIONS OPTIONS

View File

@ -41,6 +41,7 @@ for k, v in pairs({
snippet = true, snippet = true,
pack = true, pack = true,
_watch = true, _watch = true,
net = true,
}) do }) do
vim._submodules[k] = v vim._submodules[k] = v
end end

View File

@ -428,6 +428,72 @@ local function check_external_tools()
else else
health.warn('git not available (required by `vim.pack`)') health.warn('git not available (required by `vim.pack`)')
end end
if vim.fn.executable('curl') == 1 then
local curl_path = vim.fn.exepath('curl')
local curl_job = vim.system({ curl_path, '--version' }):wait()
if curl_job.code == 0 then
local curl_out = curl_job.stdout
if not curl_out or curl_out == '' then
health.warn(
string.format('`%s --version` produced no output', curl_path),
{ curl_job.stderr }
)
return
end
local curl_version = vim.version.parse(curl_out)
if not curl_version then
health.warn('Unable to parse curl version from `curl --version`')
return
end
if vim.version.le(curl_version, { 7, 12, 3 }) then
health.warn('curl version %s not compatible', curl_version)
return
end
local lines = { string.format('curl %s (%s)', curl_version, curl_path) }
for line in vim.gsplit(curl_out, '\n', { plain = true }) do
if line ~= '' and not line:match('^curl') then
table.insert(lines, line)
end
end
-- Add subtitle only if any env var is present
local added_env_header = false
for _, var in ipairs({
'curl_ca_bundle',
'curl_home',
'curl_ssl_backend',
'ssl_cert_dir',
'ssl_cert_file',
'https_proxy',
'http_proxy',
'all_proxy',
'no_proxy',
}) do
---@type string?
local val = vim.env[var] or vim.env[var:upper()]
if val then
if not added_env_header then
table.insert(lines, 'curl-related environment variables:')
added_env_header = true
end
local shown_var = vim.env[var] and var or var:upper()
table.insert(lines, string.format(' %s=%s', shown_var, val))
end
end
health.ok(table.concat(lines, '\n'))
else
health.warn('curl is installed but failed to run `curl --version`', { curl_job.stderr })
end
else
health.error('curl not found', {
'Required for vim.net.request() to function.',
'Install curl using your package manager.',
})
end
end end
function M.check() function M.check()

63
runtime/lua/vim/net.lua Normal file
View File

@ -0,0 +1,63 @@
local M = {}
--- 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 completetion.
---
--- @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
--- completetion. 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)
vim.validate({
url = { url, 'string' },
opts = { opts, 'table', true },
on_response = { on_response, 'function' },
})
opts = opts or {}
local retry = opts.retry or 3
-- Build curl command
local args = { 'curl' }
if opts.verbose then
table.insert(args, '--verbose')
else
vim.list_extend(args, { '--silent', '--show-error', '--fail' })
end
vim.list_extend(args, { '--location', '--retry', tostring(retry) })
if opts.outpath then
vim.list_extend(args, { '--output', opts.outpath })
end
table.insert(args, url)
local function on_exit(res)
local err_msg = nil
local response = nil
if res.code ~= 0 then
err_msg = (res.stderr ~= '' and res.stderr)
or string.format('Request failed with exit code %d', res.code)
else
response = {
body = opts.outpath and true or res.stdout,
}
end
if on_response then
on_response(err_msg, response)
end
end
vim.system(args, {}, on_exit)
end
return M

View File

@ -38,7 +38,7 @@ augroup END
augroup Network 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://*,http://*,https://*,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://*,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 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>"))

View File

@ -0,0 +1,43 @@
vim.g.loaded_remote_file_loader = true
--- Callback for BufReadCmd on remote URLs.
--- @param args { buf: integer }
local function on_remote_read(args)
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 = args.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.fn.winrestview(view)
vim.api.nvim_echo({ { 'Loaded ' .. url, 'Normal' } }, true, {})
end)
)
end
vim.api.nvim_create_autocmd('BufReadCmd', {
group = vim.api.nvim_create_augroup('nvim.net.remotefile', {}),
pattern = { 'http://*', 'https://*' },
desc = 'Edit remote files (:edit https://example.com)',
callback = on_remote_read,
})

View File

@ -161,6 +161,7 @@ local config = {
'snippet.lua', 'snippet.lua',
'text.lua', 'text.lua',
'tohtml.lua', 'tohtml.lua',
'net.lua',
}, },
files = { files = {
'runtime/lua/vim/iter.lua', 'runtime/lua/vim/iter.lua',
@ -192,6 +193,7 @@ local config = {
'runtime/lua/vim/_meta/re.lua', 'runtime/lua/vim/_meta/re.lua',
'runtime/lua/vim/_meta/spell.lua', 'runtime/lua/vim/_meta/spell.lua',
'runtime/lua/tohtml.lua', 'runtime/lua/tohtml.lua',
'runtime/lua/vim/net.lua',
}, },
fn_xform = function(fun) fn_xform = function(fun)
if contains(fun.module, { 'vim.uri', 'vim.shared', 'vim._editor' }) then if contains(fun.module, { 'vim.uri', 'vim.shared', 'vim._editor' }) then

View File

@ -521,6 +521,10 @@ Number; !must be defined to function properly):
`NVIM_TEST_CORE_GLOB_DIRECTORY` is defined and this variable is not) cores `NVIM_TEST_CORE_GLOB_DIRECTORY` is defined and this variable is not) cores
are checked for after each test. are checked for after each test.
- `NVIM_TEST_INTEG` (F) (D): enables integration tests that makes real network
calls. By default these tests are skipped. When set to `1`, tests requiring external
HTTP requests (e.g `vim.net.request()`) will be run.
- `NVIM_TEST_RUN_TESTTEST` (U) (1): allows running - `NVIM_TEST_RUN_TESTTEST` (U) (1): allows running
`test/unit/testtest_spec.lua` used to check how testing infrastructure works. `test/unit/testtest_spec.lua` used to check how testing infrastructure works.

View File

@ -0,0 +1,60 @@
local n = require('test.functional.testnvim')()
local t = require('test.testutil')
local skip_integ = os.getenv('NVIM_TEST_INTEG') ~= '1'
local exec_lua = n.exec_lua
local function assert_404_error(err)
assert(
err:lower():find('404') or err:find('22'),
'Expected HTTP 404 or exit code 22, got: ' .. tostring(err)
)
end
describe('vim.net.request', function()
before_each(function()
n:clear()
end)
it('fetches a URL into memory (async success)', function()
t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test')
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)
assert(not err, err)
result = body.body
done = true
end)
vim.wait(2000, function() return done end)
return result
]])
assert(
content and content:find('"url"%s*:%s*"https://httpbingo.org/anything"'),
'Expected response body to contain the correct URL'
)
end)
it('calls on_response with error on 404 (async failure)', function()
t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test')
local err = exec_lua([[
local done = false
local result
local M = require('vim.net')
M.request("https://httpbingo.org/status/404", {}, function(e, _)
result = e
done = true
end)
vim.wait(2000, function() return done end)
return result
]])
assert_404_error(err)
end)
end)