neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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:
Mruntime/doc/lua.txt | 28++++++++++++++++++++++++++++
Mruntime/doc/news.txt | 1+
Mruntime/lua/vim/_editor.lua | 1+
Mruntime/lua/vim/health/health.lua | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aruntime/lua/vim/net.lua | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mruntime/pack/dist/opt/netrw/plugin/netrwPlugin.vim | 2+-
Aruntime/plugin/nvim/net.lua | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/gen/gen_vimdoc.lua | 2++
Mtest/README.md | 4++++
Atest/functional/lua/net_spec.lua | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)