commit 7cd5356a6f89a46d83bbba9b7f6496b67f054629
parent 444a8b3ec6375b03f1483a97095a00b067a499ec
Author: Tom Ampuero <46233260+tampueroc@users.noreply.github.com>
Date: Sun, 13 Jul 2025 21:43:11 +0100
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
Diffstat:
10 files changed, 269 insertions(+), 1 deletion(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -5043,4 +5043,32 @@ tohtml.tohtml({winid}, {opt}) *tohtml.tohtml.tohtml()*
(`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:
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -90,6 +90,7 @@ LSP
LUA
• Renamed `vim.diff` to `vim.text.diff`.
+• |vim.net.request()| adds a minimal HTTP GET API using curl.
OPTIONS
diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua
@@ -41,6 +41,7 @@ for k, v in pairs({
snippet = true,
pack = true,
_watch = true,
+ net = true,
}) do
vim._submodules[k] = v
end
diff --git a/runtime/lua/vim/health/health.lua b/runtime/lua/vim/health/health.lua
@@ -428,6 +428,72 @@ local function check_external_tools()
else
health.warn('git not available (required by `vim.pack`)')
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
function M.check()
diff --git a/runtime/lua/vim/net.lua b/runtime/lua/vim/net.lua
@@ -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
diff --git a/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim b/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim
@@ -38,7 +38,7 @@ augroup END
augroup Network
au!
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 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>"))
diff --git a/runtime/plugin/nvim/net.lua b/runtime/plugin/nvim/net.lua
@@ -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,
+})
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
@@ -161,6 +161,7 @@ local config = {
'snippet.lua',
'text.lua',
'tohtml.lua',
+ 'net.lua',
},
files = {
'runtime/lua/vim/iter.lua',
@@ -192,6 +193,7 @@ local config = {
'runtime/lua/vim/_meta/re.lua',
'runtime/lua/vim/_meta/spell.lua',
'runtime/lua/tohtml.lua',
+ 'runtime/lua/vim/net.lua',
},
fn_xform = function(fun)
if contains(fun.module, { 'vim.uri', 'vim.shared', 'vim._editor' }) then
diff --git a/test/README.md b/test/README.md
@@ -521,6 +521,10 @@ Number; !must be defined to function properly):
`NVIM_TEST_CORE_GLOB_DIRECTORY` is defined and this variable is not) cores
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
`test/unit/testtest_spec.lua` used to check how testing infrastructure works.
diff --git a/test/functional/lua/net_spec.lua b/test/functional/lua/net_spec.lua
@@ -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)