mirror of
https://github.com/neovim/neovim
synced 2025-07-15 16:51:49 +00:00
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:
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -41,6 +41,7 @@ for k, v in pairs({
|
||||
snippet = true,
|
||||
pack = true,
|
||||
_watch = true,
|
||||
net = true,
|
||||
}) do
|
||||
vim._submodules[k] = v
|
||||
end
|
||||
|
@ -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()
|
||||
|
63
runtime/lua/vim/net.lua
Normal file
63
runtime/lua/vim/net.lua
Normal 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
|
@ -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>"))
|
||||
|
43
runtime/plugin/nvim/net.lua
Normal file
43
runtime/plugin/nvim/net.lua
Normal 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,
|
||||
})
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
60
test/functional/lua/net_spec.lua
Normal file
60
test/functional/lua/net_spec.lua
Normal 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)
|
Reference in New Issue
Block a user