From 7cd5356a6f89a46d83bbba9b7f6496b67f054629 Mon Sep 17 00:00:00 2001 From: Tom Ampuero <46233260+tampueroc@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:43:11 +0100 Subject: [PATCH] feat(net): vim.net.request(), :edit [url] #34140 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- runtime/doc/lua.txt | 28 ++++++++ runtime/doc/news.txt | 1 + runtime/lua/vim/_editor.lua | 1 + runtime/lua/vim/health/health.lua | 66 +++++++++++++++++++ runtime/lua/vim/net.lua | 63 ++++++++++++++++++ .../dist/opt/netrw/plugin/netrwPlugin.vim | 2 +- runtime/plugin/nvim/net.lua | 43 ++++++++++++ src/gen/gen_vimdoc.lua | 2 + test/README.md | 4 ++ test/functional/lua/net_spec.lua | 60 +++++++++++++++++ 10 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 runtime/lua/vim/net.lua create mode 100644 runtime/plugin/nvim/net.lua create mode 100644 test/functional/lua/net_spec.lua diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 95e9e07bc5..e1b429ea89 100644 --- 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 index 5fe244270d..a05a8c0452 100644 --- 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 index dd9fc221d1..50fac8cf9e 100644 --- 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 index e942ee789c..a640a6a548 100644 --- 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 new file mode 100644 index 0000000000..199b115e93 --- /dev/null +++ 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 index b1f254ad10..0bd6fbf73a 100644 --- 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("")) - au BufReadCmd ftp://*,rcp://*,scp://*,http://*,https://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufReadPre ".fnameescape(expand(""))|call netrw#Nread(2,expand(""))|exe "sil doau BufReadPost ".fnameescape(expand("")) + au BufReadCmd ftp://*,rcp://*,scp://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufReadPre ".fnameescape(expand(""))|call netrw#Nread(2,expand(""))|exe "sil doau BufReadPost ".fnameescape(expand("")) au FileReadCmd ftp://*,rcp://*,scp://*,http://*,file://*,https://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileReadPre ".fnameescape(expand(""))|call netrw#Nread(1,expand(""))|exe "sil doau FileReadPost ".fnameescape(expand("")) au BufWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau BufWritePre ".fnameescape(expand(""))|exe 'Nwrite '.fnameescape(expand(""))|exe "sil doau BufWritePost ".fnameescape(expand("")) au FileWriteCmd ftp://*,rcp://*,scp://*,http://*,file://*,dav://*,davs://*,rsync://*,sftp://* exe "sil doau FileWritePre ".fnameescape(expand(""))|exe "'[,']".'Nwrite '.fnameescape(expand(""))|exe "sil doau FileWritePost ".fnameescape(expand("")) diff --git a/runtime/plugin/nvim/net.lua b/runtime/plugin/nvim/net.lua new file mode 100644 index 0000000000..74ddaf8d9e --- /dev/null +++ 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 index 3ffca47d40..d712e41b7a 100755 --- 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 index 5b225980a2..c1ad26a396 100644 --- 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 new file mode 100644 index 0000000000..6461f28473 --- /dev/null +++ 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)