diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 5e03ba728d..b676f3203d 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -4149,29 +4149,62 @@ vim.mpack.encode({obj}) *vim.mpack.encode()* ============================================================================== Lua module: vim.net *vim.net* +*vim.net.request.Response* + + Fields: ~ + • {body} (`string`) The HTTP body of the request + + 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 completion. + This function is asynchronous (non-blocking), returning immediately and + passing the response object to the optional `on_response` handler on + completion. + + Examples: >lua + -- Write response body to file + vim.net.request('https://neovim.io/charter/', { + outpath = 'vision.html', + }) + + -- Process the response + vim.net.request( + 'https://api.github.com/repos/neovim/neovim', + {}, + function (err, res) + if err then return end + local stars = vim.json.decode(res.body).stargazers_count + vim.print(('Neovim currently has %d stars'):format(stars)) + end + ) + + -- Write to both file and current buffer, but cancel it + local job = vim.net.request('https://neovim.io/charter/', { + outpath = 'vision.html', + outbuf = 0, + }) + job:close() +< 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 + • {opts} (`table?`) A table with the following fields: + • {verbose}? (`boolean`) Enables verbose output. + • {retry}? (`integer`) 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 })`) + • {outpath}? (`string`) File path to save the response + body to. + • {outbuf}? (`integer`) Buffer to save the response + body to. + • {on_response} (`fun(err: string?, response: vim.net.request.Response?)?`) Callback invoked on request completion. 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. + field in the response parameter contains the raw + response data (text or binary). + + Return: ~ + (`{ close: fun() }`) Table with method to cancel, similar to + |vim.SystemObj|. ============================================================================== diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index e7ecccb01d..8535fbb7fa 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -26,6 +26,11 @@ EXPERIMENTS • todo +LUA + +• |vim.net.request()| does not set the response body to `true` if an output + buffer is supplied. + LSP • |vim.lsp.buf.selection_range()| now accepts an integer which specifies its diff --git a/runtime/lua/vim/net.lua b/runtime/lua/vim/net.lua index 910198fb9b..ad9e39e35c 100644 --- a/runtime/lua/vim/net.lua +++ b/runtime/lua/vim/net.lua @@ -1,23 +1,67 @@ local M = {} +---@class vim.net.request.Opts +---@inlinedoc +--- +---Enables verbose output. +---@field verbose? boolean +--- +---Number of retries on transient failures (default: 3). +---@field retry? integer +--- +---File path to save the response body to. +---@field outpath? string +--- +---Buffer to save the response body to. +---@field outbuf? integer + +---@class vim.net.request.Response +--- +---The HTTP body of the request +---@field body string + --- 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 completion. +--- This function is asynchronous (non-blocking), returning immediately and +--- passing the response object to the optional `on_response` handler on +--- completion. +--- +--- Examples: +--- ```lua +--- -- Write response body to file +--- vim.net.request('https://neovim.io/charter/', { +--- outpath = 'vision.html', +--- }) +--- +--- -- Process the response +--- vim.net.request( +--- 'https://api.github.com/repos/neovim/neovim', +--- {}, +--- function (err, res) +--- if err then return end +--- local stars = vim.json.decode(res.body).stargazers_count +--- vim.print(('Neovim currently has %d stars'):format(stars)) +--- end +--- ) +--- +--- -- Write to both file and current buffer, but cancel it +--- local job = vim.net.request('https://neovim.io/charter/', { +--- outpath = 'vision.html', +--- outbuf = 0, +--- }) +--- job:close() +--- ``` --- --- @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 ---- completion. 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. +--- @param opts? vim.net.request.Opts +--- @param on_response? fun(err: string?, response: vim.net.request.Response?) +--- Callback invoked on request completion. The `body` field in the response +--- parameter contains the raw response data (text or binary). +--- @return { close: fun() } # Table with method to cancel, similar to [vim.SystemObj]. function M.request(url, opts, on_response) vim.validate('url', url, 'string') vim.validate('opts', opts, 'table', true) - vim.validate('on_response', on_response, 'function') + vim.validate('on_response', on_response, 'function', true) opts = opts or {} local retry = opts.retry or 3 @@ -37,19 +81,39 @@ function M.request(url, opts, on_response) table.insert(args, url) - local function on_exit(res) - local s = 'Request failed with exit code %d' - local err_msg = res.code ~= 0 - and ((res.stderr ~= '' and res.stderr) or string.format(s, res.code)) - or nil - local response = res.code == 0 and { body = opts.outpath and true or res.stdout } or nil + local job = vim.system(args, {}, function(res) + ---@type string?, vim.net.request.Response? + local err, response = nil, nil + if res.signal ~= 0 then + err = ('Request killed with signal %d'):format(res.signal) + elseif res.code ~= 0 then + err = res.stderr ~= '' and res.stderr or ('Request failed with exit code %d'):format(res.code) + else + if not (opts.outpath or opts.outbuf) then + response = { + body = res.stdout --[[@as string]], + } + end + end + + -- nvim_buf_is_loaded and nvim_buf_set_lines are not allowed in fast context + vim.schedule(function() + if res.code == 0 and opts.outbuf and vim.api.nvim_buf_is_loaded(opts.outbuf) then + local lines = vim.split(res.stdout, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(opts.outbuf, 0, -1, true, lines) + end + end) if on_response then - on_response(err_msg, response) + on_response(err, response) end - end + end) - vim.system(args, {}, on_exit) + return { + close = function() + job:kill('sigint') + end, + } end return M diff --git a/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim b/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim index d2d16e6c1b..ab07df73e3 100644 --- a/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim +++ b/runtime/pack/dist/opt/netrw/plugin/netrwPlugin.vim @@ -39,7 +39,7 @@ augroup Network au! au BufReadCmd file://* call netrw#FileUrlEdit(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 FileReadCmd ftp://*,rcp://*,scp://*,file://*,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("")) try diff --git a/runtime/plugin/net.lua b/runtime/plugin/net.lua index e23c55a115..f088a8b58a 100644 --- a/runtime/plugin/net.lua +++ b/runtime/plugin/net.lua @@ -1,45 +1,70 @@ -vim.g.loaded_remote_file_loader = true - ---- Callback for BufReadCmd on remote URLs. ---- @param ev { buf: integer } -local function on_remote_read(ev) - 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 = ev.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.api.nvim_exec_autocmds('BufRead', { group = 'filetypedetect', buffer = bufnr }) - vim.bo[bufnr].modified = false - - vim.fn.winrestview(view) - vim.api.nvim_echo({ { 'Loaded ' .. url, 'Normal' } }, true, {}) - end) - ) +if vim.g.loaded_nvim_net_plugin ~= nil then + return end +vim.g.loaded_nvim_net_plugin = true + +local augroup = vim.api.nvim_create_augroup('nvim.net.remotefile', {}) +local url_patterns = { 'http://*', 'https://*' } vim.api.nvim_create_autocmd('BufReadCmd', { - group = vim.api.nvim_create_augroup('nvim.net.remotefile', {}), - pattern = { 'http://*', 'https://*' }, + group = augroup, + pattern = url_patterns, desc = 'Edit remote files (:edit https://example.com)', - callback = on_remote_read, + callback = function(ev) + if vim.fn.executable('curl') ~= 1 then + vim.notify('vim.net.request: curl not found', vim.log.levels.WARN) + return + end + + local url = ev.file + vim.notify(('Fetching %s …'):format(url), vim.log.levels.INFO) + + vim.net.request( + url, + { outbuf = ev.buf }, + vim.schedule_wrap(function(err, _) + if err then + vim.notify(('Failed to fetch %s: %s'):format(url, err), vim.log.levels.ERROR) + return + end + + vim.api.nvim_exec_autocmds('BufRead', { group = 'filetypedetect', buffer = ev.buf }) + vim.bo[ev.buf].modified = false + vim.notify(('Loaded %s'):format(url), vim.log.levels.INFO) + end) + ) + end, +}) + +vim.api.nvim_create_autocmd('FileReadCmd', { + group = augroup, + pattern = url_patterns, + desc = 'Read remote files (:read https://example.com)', + callback = function(ev) + if vim.fn.executable('curl') ~= 1 then + vim.notify('vim.net.request: curl not found', vim.log.levels.WARN) + return + end + + local url = ev.file + vim.notify(('Fetching %s …'):format(url), vim.log.levels.INFO) + + vim.net.request( + url, + {}, + vim.schedule_wrap(function(err, response) + if err or not response then + vim.notify(('Failed to fetch %s: %s'):format(url, err), vim.log.levels.ERROR) + return + end + + -- Start inserting the response at the line number given by read (e.g. :10read). + -- FIXME: Doesn't work for :0read as '[ is set to 1. See #7177 for possible solutions. + local start = vim.fn.line("'[") + local lines = vim.split(response.body or '', '\n', { plain = true }) + vim.api.nvim_buf_set_lines(ev.buf, start, start, true, lines) + vim.notify(('Loaded %s'):format(url), vim.log.levels.INFO) + end) + ) + end, }) diff --git a/test/functional/lua/net_spec.lua b/test/functional/lua/net_spec.lua index 93ddab8c3c..253f7c30d4 100644 --- a/test/functional/lua/net_spec.lua +++ b/test/functional/lua/net_spec.lua @@ -21,9 +21,8 @@ describe('vim.net.request', function() 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) + vim.net.request("https://httpbingo.org/anything", { retry = 3 }, function(err, body) assert(not err, err) result = body.body done = true @@ -63,9 +62,8 @@ describe('vim.net.request', function() local err = exec_lua([[ local done = false local result - local M = require('vim.net') - M.request("https://httpbingo.org/status/404", {}, function(e, _) + vim.net.request("https://httpbingo.org/status/404", {}, function(e, _) result = e done = true end) @@ -76,4 +74,43 @@ describe('vim.net.request', function() assert_404_error(err) end) + + it('plugin writes output to buffer', function() + t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + local content = exec_lua([[ + local lines + + local buf = vim.api.nvim_create_buf(false, true) + vim.net.request("https://httpbingo.org", { outbuf = buf }) + + vim.wait(2000, function() + lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + return lines[1] ~= "" + end) + + return lines + ]]) + assert(content and content[1]:find('html')) + end) + + it('works with :read', function() + t.skip(skip_integ, 'NVIM_TEST_INTEG not set: skipping network integration test') + local content = exec_lua([[ + vim.cmd('runtime plugin/net.lua') + local lines + + vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'Here is some text' }) + vim.cmd(':read https://example.com') + + vim.wait(2000, function() + lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + return #lines > 1 + end) + + return lines + ]]) + t.eq(true, content ~= nil) + t.eq(true, content[1]:find('Here') ~= nil) + t.eq(true, content[2]:find('html') ~= nil) + end) end)