feat(uri): structured querystring, join(), new(), normalize() #42705

Problem:
- vim.uri.parse() not fully baked, it does not structure some parts of its result (esp. `query`).
- The scheme is not lowercased, which violates the RFC.
- No "constructor" for vim.Uri.
- tostring(vim.Uri) is not fully driven by the vim.Uri fields.

Solution:
- Structure `authority`, `query`.
- Lowercase the scheme.
- Drop `_parse_ssh_uri()`.
- Add `vim.uri.new()`.
- Fix tostring(vim.Uri).
- Validation.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-11-05 07:49:38 -05:00
1 parent 094621e4c9
commit 67f6f0503c
11 files changed
+883 -242

No files matched your search

+164 -26
View File
@@ -2871,6 +2871,8 @@ vim.fs.joinpath({...}) *vim.fs.joinpath()*
removed, and (on Windows) backslashes are replaced with forward-slashes.
Empty segments are removed. Paths are not expanded/resolved.
To join URI paths, see |vim.uri.join()|.
Examples:
• "foo/", "/bar" => "foo/bar"
• "", "after/plugin" => "after/plugin"
@@ -5886,42 +5888,106 @@ vim.ui.img.set({data_or_id}, {opts}) *vim.ui.img.set()*
Lua module: vim.uri *vim.uri*
vim.Uri *vim.Uri*
URI components (see |vim.uri.parse()|). `tostring()` gives the URI as a
string. Can be compared for equality (`==`): >lua
vim.uri.parse('HTTP://h/a') == vim.uri.new({ scheme = 'http', host = 'h', path = '/a' }) --> true
<
Fields: ~
• {authority}? (`string`) Authority (after "//"), e.g.
"user@example.com:8080". Empty for "file:///a", nil for
"file:/a".
• {fragment}? (`string`) Fragment (after "#"), e.g. "section".
• {path} (`string`) Path, e.g. "/a/b". Empty for
"https://example.com".
• {query}? (`string`) Query (after "?"), e.g. "q=1".
• {scheme} (`string`) Scheme, e.g. "https".
• {fragment}? (`string`) Fragment (URI-encoded): "https://h#a%20b" =>
"a%20b".
• {host}? (`string`) Host (lowercase, URI-decoded):
`ssh://Me@[::1]:22` => "::1", "https://Example.com" =>
"example.com". Empty for "file:///a", nil for "file:/a".
• {password}? (`string`) Password (URI-decoded):
"https://u:p%40ss@host" => "p@ss".
• {path} (`string`) Path (URI-encoded): "https://h/a%20b" =>
"/a%20b". Empty for "https://h".
• {port}? (`integer`) Port: "https://example.com:8080" => 8080.
• {query}? (`table<string, string|string[]>`) Query (decoded by
|vim.uri.decode_query()|): "https://h?q=a+b" =>
`{ q = "a b" }`.
• {querystr}? (`string`) Querystring (URI-encoded): "https://h?q=a+b"
=> "q=a+b". Takes precedence over `query` in
`tostring()`.
• {scheme} (`string`) Scheme (lowercase): "HTTPS://example.com" =>
"https".
• {user}? (`string`) User (URI-decoded): "ssh://a%40b@host" =>
"a@b".
vim.uri.decode({str}) *vim.uri.decode()*
Decodes a URI-encoded string.
Decodes a URI-encoded string. (Use |vim.uri.parse()| to get the structured
form.)
Attributes: ~
Since: 0.13.0
Parameters: ~
• {str} (`string`) string to decode
• {str} (`string`) URI-encoded string.
Return: ~
(`string`) decoded string
(`string`) Decoded string.
vim.uri.decode_query({query}) *vim.uri.decode_query()*
Decodes a querystring (`application/x-www-form-urlencoded`, as HTML forms
send it): "+" is space, and a repeated key gives a list.
Example: >lua
vim.uri.decode_query('q=a+b%26c&tag=x&tag=y&flag')
--> { q = 'a b&c', tag = { 'x', 'y' }, flag = '' }
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {query} (`string?`) Querystring, e.g. a form body.
Return: ~
(`table<string, string|string[]>`)
vim.uri.encode({str}, {rfc}) *vim.uri.encode()*
URI-encodes a string.
URI-encodes (percent-encodes) a string. For a querystring, use
|vim.uri.encode_query()|.
Example: >lua
vim.uri.encode('/a b?') --> '/a%20b%3f'
vim.uri.encode('/a b?', 'rfc2396') --> '%2fa%20b%3f'
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {str} (`string`) string to encode
• {rfc} (`"rfc2396"|"rfc2732"|"rfc3986"?`)
• {str} (`string`) Arbitrary string.
• {rfc} (`"rfc2396"|"rfc2732"|"rfc3986"?`) Chars to keep:
• "rfc2396": only `A-Za-z0-9-_.!~*'()`.
• "rfc2732": "rfc2396" plus `[` and `]`.
• "rfc3986" (default): those valid in a path, e.g. "/", ":",
"@", "&", "=".
Return: ~
(`string`) encoded string
(`string`) URI-encoded string.
vim.uri.encode_query({params}) *vim.uri.encode_query()*
Encodes `params` as a querystring (`application/x-www-form-urlencoded`, as
HTML forms send it): space is "+", and a list value repeats its key. Sorts
the keys.
Example: >lua
vim.uri.encode_query({ q = 'a b&c', tag = { 'x', 'y' }, n = 1 })
--> 'n=1&q=a+b%26c&tag=x&tag=y'
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {params} (`table<string, string|number|boolean|(string|number|boolean)[]>`)
Return: ~
(`string`)
vim.uri.from_buf({buf}) *vim.uri.from_buf()*
Gets a URI from a buffer.
@@ -5936,14 +6002,16 @@ vim.uri.from_buf({buf}) *vim.uri.from_buf()*
(`string`) URI
vim.uri.from_fname({path}) *vim.uri.from_fname()*
Gets a URI from a file path. Relative paths are resolved to
|current-directory| (RFC 8089 disallows relative URIs).
Gets a URI from a file path, or returns `path` if already a URI. Relative
paths are resolved to |current-directory| (RFC 8089 disallows relative
URIs).
Example: >lua
vim.uri.from_fname('/a b/c#') --> 'file:///a%20b/c%23'
vim.uri.from_fname('C:\\a\\b') --> 'file:///C:/a/b'
vim.uri.from_fname('//server/share/a') --> 'file://server/share/a' (UNC)
vim.uri.from_fname('a') --> 'file:///home/user/a' (if CWD is "/home/user")
vim.uri.from_fname('jdt://a/b') --> 'jdt://a/b'
<
Attributes: ~
@@ -5955,16 +6023,86 @@ vim.uri.from_fname({path}) *vim.uri.from_fname()*
Return: ~
(`string`) URI
vim.uri.parse({uri}) *vim.uri.parse()*
Parses a URI into its RFC 3986 components, without decoding them. Unlike
RFC 3986, a one-letter scheme is a drive letter, so `C:/a` is not a URI.
vim.uri.join({base}, {...}) *vim.uri.join()*
Appends path segments to the path of `base`, like |vim.fs.joinpath()|, and
removes "." and ".." segments, but never above the root or a Windows drive
letter. Each segment is URI-encoded, so "#" or "?" in a file name is part
of the path.
Example: >lua
vim.uri.parse('https://example.com/a%20b?q=1#section')
--> { scheme = 'https', authority = 'example.com', path = '/a%20b', query = 'q=1', fragment = 'section' }
tostring(vim.uri.join('file:///home/u/proj', 'src', 'a#1.lua'))
--> 'file:///home/u/proj/src/a%231.lua'
tostring(vim.uri.join('file:///C:/a', '../../x')) --> 'file:///C:/x'
tostring(vim.uri.join('https://h/api/?v=1', 'users/')) --> 'https://h/api/users/?v=1'
vim.uri.join('file:///a', 'file:///b') --> error: a segment can't be a URI
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {base} (`string|vim.Uri`)
• {...} (`string`) Path segments (not URI-encoded), not URIs.
Return: ~
(`vim.Uri`)
vim.uri.new({uri}) *vim.uri.new()*
Creates a |vim.Uri| from components.
Example: >lua
tostring(vim.uri.new({ scheme = 'https', host = '::1', port = 8080, path = '/a' }))
--> 'https://[::1]:8080/a'
tostring(vim.uri.new({ scheme = 'https', host = 'h', path = '/s', query = { q = 'a b' } }))
--> 'https://h/s?q=a+b'
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {uri} (`vim.Uri`) `path` defaults to "".
Return: ~
(`vim.Uri`)
vim.uri.normalize({uri}) *vim.uri.normalize()*
Normalizes `uri` (RFC 3986 6), so equivalent URIs are equal (`==`).
Besides the normalizations of |vim.uri.parse()| and `tostring()`, it
normalizes percent-encoding, removes "." and ".." segments, and drops a
default port.
Example: >lua
tostring(vim.uri.normalize('HTTPS://Example.COM:443/a/./b/../%7e%2f'))
--> 'https://example.com/a/~%2F'
vim.uri.normalize('HTTP://h:80') == vim.uri.normalize('http://h/') --> true
<
Attributes: ~
Since: 0.13.0
Parameters: ~
• {uri} (`string|vim.Uri`)
Return: ~
(`vim.Uri`)
vim.uri.parse({uri}) *vim.uri.parse()*
Parses a URI into its RFC 3986 components. Exception: a one-letter scheme
is a drive letter, "C:/a" is not a URI.
Example: >lua
vim.uri.parse('https://u:p%40ss@example.com:80/a%20b?q=1#section')
--> { scheme = 'https', user = 'u', password = 'p@ss', host = 'example.com', port = 80,
path = '/a%20b', query = { q = '1' }, querystr = 'q=1', fragment = 'section' }
vim.uri.parse('/a/b') --> nil
vim.uri.parse('C:/a') --> nil
tostring(vim.uri.parse('file:///a?b')) --> 'file:///a?b'
vim.uri.parse('https://h:x') --> nil
-- tostring() builds the URI from the fields.
local u = vim.uri.parse('https://example.com/a')
u.host, u.port = '::1', 8080
tostring(u) --> 'https://[::1]:8080/a'
<
Attributes: ~
@@ -5974,7 +6112,7 @@ vim.uri.parse({uri}) *vim.uri.parse()*
• {uri} (`string`)
Return: ~
(`vim.Uri?`) URI components, or nil if `uri` has no scheme.
(`vim.Uri?`) URI components, or nil if invalid.
vim.uri.to_buf({uri}) *vim.uri.to_buf()*
Gets the buffer for a uri. Creates a new unloaded buffer if no buffer for
@@ -5984,7 +6122,7 @@ vim.uri.to_buf({uri}) *vim.uri.to_buf()*
Since: 0.13.0
Parameters: ~
• {uri} (`string`)
• {uri} (`string|vim.Uri`)
Return: ~
(`integer`) buf
@@ -6004,7 +6142,7 @@ vim.uri.to_fname({uri}) *vim.uri.to_fname()*
Since: 0.13.0
Parameters: ~
• {uri} (`string`)
• {uri} (`string|vim.Uri`)
Return: ~
(`string`) filename or unchanged URI for non-file URIs
+7 -1
View File
@@ -161,7 +161,7 @@ EVENTS
• |BufModifiedSet| has been removed. Use the |OptionSet| event with pattern
"modified" instead.
• |ui-messages| `msg_show.completion` messages are now `msg_show.progress`
events. If :write or :edit is slow it emits `msg_show.progress` events.
events.
LSP
@@ -453,6 +453,11 @@ LUA
• |vim.ui.input()| now allows setting input scope.
• |vim.uri| is a module. The old `uri_xx` top-level functions were renamed,
e.g. `vim.uri_to_fname()` is now |vim.uri.to_fname()|.
• Parse/build URIs with |vim.uri.parse()|, |vim.uri.new()|,
|vim.uri.join()|.
• Check equality with |vim.uri.normalize()|.
• Parse/build querystrings with |vim.uri.encode_query()| and
|vim.uri.decode_query()|.
• |vim.log| provides a logging interface.
• |vim.pack.get()| output includes revision of a pending update.
• |vim.pack.get()| can fetch new updates before computing the output.
@@ -492,6 +497,7 @@ OPTIONS
PERFORMANCE
• If :write or :edit is slow it emits `msg_show.progress` events.
• LSP shares identical file watcher registrations, reducing startup work in
large workspaces. On macOS and Windows, watchers with different glob
patterns share one native watcher per directory, which reduces cleanup work
+4 -2
View File
@@ -133,6 +133,8 @@ end
--- paths). Slashes are normalized: redundant slashes are removed, and (on Windows) backslashes are
--- replaced with forward-slashes. Empty segments are removed. Paths are not expanded/resolved.
---
--- To join URI paths, see |vim.uri.join()|.
---
--- Examples:
--- - "foo/", "/bar" => "foo/bar"
--- - "", "after/plugin" => "after/plugin"
@@ -196,7 +198,7 @@ function M.slug(path, opts)
-- Capture URI scheme before normalize() collapses "://" to ":/" (which looks like "C:/")
local uri = uri_parse(path)
local uri_scheme = uri and uri.authority and uri.scheme
local uri_scheme = uri and uri.host and uri.scheme
-- Normalize before computing the hash so equivalent paths produce the same result
path = vim.fs.normalize(path, { plain = true })
@@ -664,7 +666,7 @@ function M.root(source, marker)
error('invalid type for argument "source": expected string or buffer number')
end
local uri = uri_parse(path)
if uri and uri.scheme:lower() == 'file' then
if uri and uri.scheme == 'file' then
path = vim.uri.to_fname(path)
elseif is_url(path) then
path = assert(uv.cwd())
+5 -4
View File
@@ -620,10 +620,10 @@ local function check_external_tools()
return
end
if vim.version.le(curl_version, { 7, 12, 3 }) then
health.warn(('curl version %s not compatible'):format(curl_version))
health.warn(('curl version %s not compatible'):format(tostring(curl_version)))
return
end
local lines = { string.format('curl %s (%s)', curl_version, curl_path) }
local lines = { ('curl %s (%s)'):format(tostring(curl_version), curl_path) }
for line in vim.gsplit(curl_out, '\n', { plain = true }) do
if line ~= '' then
@@ -864,8 +864,9 @@ local function check_sysinfo()
if win == -1 then
return
end
local encoded_body = vim.uri.encode(body) --- @type string
local issue_url = 'https://github.com/neovim/neovim/issues/new?type=Bug&body=' .. encoded_body
local issue_url = ('https://github.com/neovim/neovim/issues/new?%s'):format(
vim.uri.encode_query({ type = 'Bug', body = body })
)
--- Opens the prefilled issue from the checkhealth winbar.
---@diagnostic disable-next-line: global-in-non-module
+2 -1
View File
@@ -1122,12 +1122,13 @@ function Client:_get_registrations(provider, bufnr)
else
local language = self:_get_language_id(bufnr)
local uri = vim.uri.from_buf(bufnr)
local scheme = assert(vim.uri.parse(uri)).scheme
local fname = vim.uri.to_fname(uri)
for _, filter in ipairs(regoptions.documentSelector) do
local flang, fscheme, fpat = filter.language, filter.scheme, filter.pattern
if
not (flang and language ~= flang)
and not (fscheme and not vim.startswith(uri, fscheme .. ':'))
and not (fscheme and fscheme:lower() ~= scheme)
and not (type(fpat) == 'string' and not self:_glob_matches(fpat, fname))
then
matched_regs[#matched_regs + 1] = reg
+9 -5
View File
@@ -320,7 +320,7 @@ end
---
--- `ssh_args` are inserted before the target; `remote_cmd` is appended after the target.
---
---@param ssh_uri {host:string, user?:string, port?:string, config?:string}
---@param ssh_uri vim.Uri & {host:string, config?:string}
---@param opts? { ssh_args?: string[], remote_cmd?: string[] }
---@return string[]
local function get_ssh_cmd(ssh_uri, opts)
@@ -348,7 +348,7 @@ local function get_ssh_cmd(ssh_uri, opts)
'RemoteCommand=none',
})
local target = ssh_uri.user and ('%s@%s'):format(ssh_uri.user, ssh_uri.host) or ssh_uri.host
local dest = ssh_uri.port and { '-p', ssh_uri.port } or {}
local dest = ssh_uri.port and { '-p', tostring(ssh_uri.port) } or {}
-- Ends option parsing, so a target like `-oProxyCommand=x` is not taken as an option.
vim.list_extend(dest, { '--', target })
@@ -384,7 +384,7 @@ end
--- Runs the local `$VIMRUNTIME/scripts/remote_ssh.sh` on the remote host. The script goes to
--- `sh -s` over stdin, so the remote login shell (any shell) parses only `args`.
---
---@param uri {host:string, user?:string, port?:string}
---@param uri vim.Uri & {host:string}
---@param args string[] Script args: plain words, which no shell expands, so they need no quoting.
---@param opts? { ssh_args?: string[] }
---@param on_exit? fun(out: vim.SystemCompleted)
@@ -404,7 +404,7 @@ end
--- Gets the operating system and architecture from the remote system.
---
---@param uri {host:string, user?:string, port?:string}
---@param uri vim.Uri & {host:string}
---@return string os, string arch
function M.get_system_info(uri)
local ssh_cmd = get_ssh_cmd(uri, { remote_cmd = { 'uname -s && uname -m' } })
@@ -497,7 +497,11 @@ end
---@param chan? integer
---@param report fun(status: 'running'|'success'|'failed', msg: string)
local function connect(uri_str, chan, report)
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
local uri = vim.uri.parse(uri_str)
if not (uri and uri.host and uri.host ~= '') then
error(('No host in SSH URI: %s'):format(uri_str), 0)
end
---@cast uri vim.Uri & {host:string}
report('running', ('Connecting to %s...'):format(uri_str))
local dir, url, version = get_release(M.get_system_info(uri))
+458 -120
View File
@@ -6,7 +6,6 @@
local M = {}
local sbyte = string.byte
local schar = string.char
local tohex = require('bit').tohex
local iswin = package.config:sub(1, 1) == '\\'
local PATTERNS = {
-- RFC 2396 https://tools.ietf.org/html/rfc2396#section-2.3
@@ -16,74 +15,102 @@ local PATTERNS = {
-- RFC 3986 https://tools.ietf.org/html/rfc3986#section-3.3
rfc3986 = "^A-Za-z0-9%-._~!$&'()*+,;=:@/",
}
-- Default ports of the WHATWG URL "special" schemes.
local default_ports = { ftp = 21, http = 80, https = 443, ws = 80, wss = 443 }
-- RFC 3986 "unreserved" and "sub-delims" chars (Lua pattern set).
local authority_chars = "A-Za-z0-9%-._~!$&'()*+,;="
-- Scheme (Lua pattern). Requires 2+ chars, so drive letters ("C:/a") are not schemes.
local scheme_pat = '%a[%w+.-]+'
---Converts hex to char
---@param hex string
--- Encodes a user, password, or host: keeps only `authority_chars`. Uses uppercase hex (RFC 3986
--- 2.1).
---@param s string
---@return string
local function hex_to_char(hex)
return schar(vim._assert_integer(hex, 16))
local function encode_authority_part(s)
return (
s:gsub(('[^%s]'):format(authority_chars), function(c)
return ('%%%02X'):format(sbyte(c))
end)
)
end
---@param char string
---@return string
local function percent_encode_char(char)
return '%' .. tohex(sbyte(char), 2)
--- Checks that `s` has only `chars` (Lua pattern set), "%XX", and non-ASCII bytes (an unencoded
--- international name).
---@param s string
---@param chars string
---@return boolean
local function has_only(s, chars)
return not s:gsub('%%%x%x', ''):find(('[^%s\128-\255]'):format(chars))
end
--- Gets the querystring of `uri`: `querystr`, else the encoded `query`.
---@param uri vim.Uri
---@return string?
local function query_string(uri)
if uri.querystr or not (uri.query and next(uri.query)) then
return uri.querystr
end
return M.encode_query(uri.query)
end
local uri_mt = {
--- @param uri vim.Uri
--- @return string
__tostring = function(uri)
local authority --- @type string?
if uri.host then
local password = uri.password and (':%s'):format(encode_authority_part(uri.password)) or ''
local userinfo = uri.user and ('%s%s@'):format(encode_authority_part(uri.user), password)
or ''
-- IPv6 in brackets, "%" of a zone ID encoded: "fe80::1%en0" => "[fe80::1%25en0]".
local host = uri.host:find(':', 1, true) and ('[%s]'):format((uri.host:gsub('%%', '%%25')))
or encode_authority_part(uri.host)
local port = uri.port and (':%d'):format(uri.port) or ''
authority = ('%s%s%s'):format(userinfo, host, port)
end
local query = query_string(uri)
return ('%s:%s%s%s%s'):format(
uri.scheme,
uri.authority and ('//%s'):format(uri.authority) or '',
authority and ('//%s'):format(authority) or '',
uri.path,
uri.query and ('?%s'):format(uri.query) or '',
query and ('?%s'):format(query) or '',
uri.fragment and ('#%s'):format(uri.fragment) or ''
)
end,
--- @param a vim.Uri
--- @param b vim.Uri
--- @return boolean
__eq = function(a, b)
return tostring(a) == tostring(b)
end,
}
--- @class vim.Uri
---
--- Scheme, e.g. "https".
--- @field scheme string
---
--- Authority (after "//"), e.g. "user@example.com:8080". Empty for "file:///a", nil for "file:/a".
--- @field authority? string
---
--- Path, e.g. "/a/b". Empty for "https://example.com".
--- @field path string
---
--- Query (after "?"), e.g. "q=1".
--- @field query? string
---
--- Fragment (after "#"), e.g. "section".
--- @field fragment? string
--- Creates a |vim.Uri| from unvalidated fields.
---@param fields vim.Uri `path` defaults to "".
---@return vim.Uri
local function _new(fields)
return setmetatable({
-- Lowercase, as specified by RFC.
scheme = fields.scheme:lower(),
user = fields.user,
password = fields.password,
host = fields.host and fields.host:lower(),
port = fields.port,
path = fields.path or '',
query = fields.query,
querystr = fields.querystr,
fragment = fields.fragment,
}, uri_mt)
end
--- Parses a URI into its RFC 3986 components, without decoding them. Unlike RFC 3986, a one-letter
--- scheme is a drive letter, so `C:/a` is not a URI.
---
--- Example:
---
--- ```lua
--- vim.uri.parse('https://example.com/a%20b?q=1#section')
--- --> { scheme = 'https', authority = 'example.com', path = '/a%20b', query = 'q=1', fragment = 'section' }
--- vim.uri.parse('/a/b') --> nil
--- vim.uri.parse('C:/a') --> nil
--- tostring(vim.uri.parse('file:///a?b')) --> 'file:///a?b'
--- ```
---
---@since 15
---@param uri string
---@return vim.Uri? # URI components, or nil if `uri` has no scheme.
function M.parse(uri)
-- Requires 2+ chars, so drive letters ("C:/a") are not schemes.
local scheme = uri:match('^(%a[%w+.-]+):')
if not scheme then
return nil
end
local rest = uri:sub(#scheme + 2)
--- Parses a URI reference (URI or relative reference, RFC 3986 4.1) into |vim.Uri| fields, with a
--- nil `scheme` for a relative reference: "../a?q" => `{ path = "../a", querystr = "q", … }`.
---@param ref string
---@return vim.Uri? # Fields, or nil if the authority is invalid.
local function parse_ref(ref)
-- Splits (RFC 3986 Appendix B).
local scheme = ref:match(('^(%s):'):format(scheme_pat)) --- @type string?
local rest = scheme and ref:sub(#scheme + 2) or ref
local fragment_i = rest:find('#', 1, true)
local fragment = fragment_i and rest:sub(fragment_i + 1) or nil
rest = rest:sub(1, (fragment_i or 0) - 1)
@@ -91,35 +118,377 @@ function M.parse(uri)
local query = query_i and rest:sub(query_i + 1) or nil
rest = rest:sub(1, (query_i or 0) - 1)
local authority, path = rest:match('^//([^/]*)(.*)$') --- @type string?, string?
return setmetatable({
path = path or rest
-- Exception: a drive letter in the host position is not a host (like WHATWG URL):
-- "file://C:/a" => host "", path "/C:/a". But "file://h:22/a" has a port.
local decoded = scheme and scheme:lower() == 'file' and authority and M.decode(authority) or ''
if decoded:find('^%a:$') or decoded:find('^%a:\\') then
authority, path = '', ('/%s%s'):format(authority, path)
end
local user, password, host, port --- @type string?, string?, string?, integer?
if authority then
local userinfo, hostport = authority:match('^(.*)@(.*)$') --- @type string?, string?
hostport = hostport or authority
-- IP literal in brackets: "[::1]:22" => "[::1]", ":22".
local h, tail = hostport:match('^(%b[])(.*)$') --- @type string?, string?
if not h then
h, tail = hostport:match('^([^:]*)(.*)$')
end
---@cast h string
---@cast tail string
local p = tail:match('^:(%d*)$') --- @type string?
local ip = h:match('^%[(.*)%]$') --- @type string?
port = vim._tointeger(p)
-- Validation
if
not (tail == '' or p)
or (port and port > 65535)
or (userinfo and not has_only(userinfo, ('%s:'):format(authority_chars)))
-- IPv6, with zone ID ("fe80::1%25en0"), or IPvFuture ("v1.x").
or (ip and not (ip:find('^[%x:.]+$') or ip:find('^[%x:.]+%%25.') or ip:find('^v%x+%.')))
or (not ip and not has_only(h, authority_chars))
then
return nil
end
if userinfo then
local u, pw = userinfo:match('^([^:]*):(.*)$') --- @type string?, string?
user, password = M.decode(u or userinfo), pw and M.decode(pw)
end
-- Drops IPv6 brackets: "[::1]" => "::1".
host = M.decode(ip or h)
end
return {
scheme = scheme,
authority = authority,
path = path or rest,
query = query,
user = user,
password = password,
host = host,
port = port,
path = path,
query = query and M.decode_query(query),
querystr = query,
fragment = fragment,
}, uri_mt)
} --[[@as vim.Uri]]
end
---URI-encodes a string.
--- URI components (see |vim.uri.parse()|). `tostring()` gives the URI as a string. Can be compared
--- for equality (`==`):
---
--- ```lua
--- vim.uri.parse('HTTP://h/a') == vim.uri.new({ scheme = 'http', host = 'h', path = '/a' }) --> true
--- ```
---
--- @class vim.Uri
---
--- Scheme (lowercase): "HTTPS://example.com" => "https".
--- @field scheme string
---
--- User (URI-decoded): "ssh://a%40b@host" => "a@b".
--- @field user? string
---
--- Password (URI-decoded): "https://u:p%40ss@host" => "p@ss".
--- @field password? string
---
--- Host (lowercase, URI-decoded): `ssh://Me@[::1]:22` => "::1", "https://Example.com" =>
--- "example.com". Empty for "file:///a", nil for "file:/a".
--- @field host? string
---
--- Port: "https://example.com:8080" => 8080.
--- @field port? integer
---
--- Path (URI-encoded): "https://h/a%20b" => "/a%20b". Empty for "https://h".
--- @field path string
---
--- Query (decoded by |vim.uri.decode_query()|): "https://h?q=a+b" => `{ q = "a b" }`.
--- @field query? table<string, string|string[]>
---
--- Querystring (URI-encoded): "https://h?q=a+b" => "q=a+b". Takes precedence over `query` in
--- `tostring()`.
--- @field querystr? string
---
--- Fragment (URI-encoded): "https://h#a%20b" => "a%20b".
--- @field fragment? string
--- Parses a URI into its RFC 3986 components. Exception: a one-letter scheme is a drive letter, "C:/a" is not a URI.
---
--- Example:
---
--- ```lua
--- vim.uri.parse('https://u:p%40ss@example.com:80/a%20b?q=1#section')
--- --> { scheme = 'https', user = 'u', password = 'p@ss', host = 'example.com', port = 80,
--- path = '/a%20b', query = { q = '1' }, querystr = 'q=1', fragment = 'section' }
--- vim.uri.parse('/a/b') --> nil
--- vim.uri.parse('C:/a') --> nil
--- vim.uri.parse('https://h:x') --> nil
---
--- -- tostring() builds the URI from the fields.
--- local u = vim.uri.parse('https://example.com/a')
--- u.host, u.port = '::1', 8080
--- tostring(u) --> 'https://[::1]:8080/a'
--- ```
---
---@since 15
---@param str string string to encode
---@param rfc "rfc2396" | "rfc2732" | "rfc3986" | nil
---@return string encoded string
---@param uri string
---@return vim.Uri? # URI components, or nil if invalid.
function M.parse(uri)
local fields = parse_ref(uri)
return fields and fields.scheme and _new(fields) or nil
end
--- Creates a |vim.Uri| from components.
---
--- Example:
---
--- ```lua
--- tostring(vim.uri.new({ scheme = 'https', host = '::1', port = 8080, path = '/a' }))
--- --> 'https://[::1]:8080/a'
--- tostring(vim.uri.new({ scheme = 'https', host = 'h', path = '/s', query = { q = 'a b' } }))
--- --> 'https://h/s?q=a+b'
--- ```
---
---@since 15
---@param uri vim.Uri `path` defaults to "".
---@return vim.Uri
function M.new(uri)
vim.validate('uri', uri, 'table')
vim.validate('uri.scheme', uri.scheme, 'string')
vim.validate('uri.port', uri.port, 'number', true)
vim.validate('uri.query', uri.query, 'table', true)
for _, k in ipairs({ 'user', 'password', 'host', 'path', 'querystr', 'fragment' }) do
vim.validate(('uri.%s'):format(k), uri[k], 'string', true)
end
local u = _new(uri)
u.query = u.query or (u.querystr and M.decode_query(u.querystr))
return u
end
--- Removes "." and ".." segments (RFC 3986 5.2.4): "/a/./b/../c" => "/a/c".
---@param path string
---@return string
local function remove_dot_segments(path)
local out = {} --- @type string[]
while path ~= '' do
local dots = path:match('^%.%.?/') --- @type string?
if dots then
path = path:sub(#dots + 1)
elseif path == '/.' or path:find('^/%./') then
path = ('/%s'):format(path:sub(4))
elseif path == '/..' or path:find('^/%.%./') then
path = ('/%s'):format(path:sub(5))
out[#out] = nil
elseif path == '.' or path == '..' then
path = ''
else
local segment = path:match('^/?[^/]*') --[[@as string]]
out[#out + 1] = segment
path = path:sub(#segment + 1)
end
end
return table.concat(out)
end
--- Appends path segments to the path of `base`, like |vim.fs.joinpath()|, and removes "." and ".."
--- segments, but never above the root or a Windows drive letter. Each segment is URI-encoded, so
--- "#" or "?" in a file name is part of the path.
---
--- Example:
---
--- ```lua
--- tostring(vim.uri.join('file:///home/u/proj', 'src', 'a#1.lua'))
--- --> 'file:///home/u/proj/src/a%231.lua'
--- tostring(vim.uri.join('file:///C:/a', '../../x')) --> 'file:///C:/x'
--- tostring(vim.uri.join('https://h/api/?v=1', 'users/')) --> 'https://h/api/users/?v=1'
--- vim.uri.join('file:///a', 'file:///b') --> error: a segment can't be a URI
--- ```
---
---@since 15
---@param base string|vim.Uri
---@param ... string Path segments (not URI-encoded), not URIs.
---@return vim.Uri
function M.join(base, ...)
vim.validate('base', base, { 'string', 'table' })
local u = type(base) == 'table' and _new(base)
or M.parse(base --[[@as string]])
or error(('Invalid URI: %s'):format(base))
local path = u.path
for i = 1, select('#', ...) do
local segment = select(i, ...) --- @type string
vim.validate('segment', segment, 'string')
if segment:find(('^%s://'):format(scheme_pat)) then
error(('Segment is a URI: %s'):format(segment))
end
path = ('%s/%s'):format(path, M.encode(segment))
end
path = path:gsub('/+', '/')
-- Keeps a drive letter as the root: "/C:/a/../.." => "/C:/".
local drive = u.scheme == 'file' and path:match('^/%a:') or ''
u.path = ('%s%s'):format(drive, remove_dot_segments(path:sub(#drive + 1)))
return u
end
--- Normalizes percent-encoding (RFC 3986 6.2.2.2): decodes "unreserved" chars, and uppercases the
--- hex of others: "%7e%2f" => "~%2F".
---@param s string
---@return string
local function normalize_percent(s)
return (
s:gsub('%%(%x%x)', function(hex)
local c = schar(vim._assert_integer(hex, 16))
return c:find('^[A-Za-z0-9%-._~]$') and c or ('%%%s'):format(hex:upper())
end)
)
end
--- Normalizes `uri` (RFC 3986 6), so equivalent URIs are equal (`==`). Besides the normalizations
--- of |vim.uri.parse()| and `tostring()`, it normalizes percent-encoding, removes "." and ".."
--- segments, and drops a default port.
---
--- Example:
---
--- ```lua
--- tostring(vim.uri.normalize('HTTPS://Example.COM:443/a/./b/../%7e%2f'))
--- --> 'https://example.com/a/~%2F'
--- vim.uri.normalize('HTTP://h:80') == vim.uri.normalize('http://h/') --> true
--- ```
---
---@since 15
---@param uri string|vim.Uri
---@return vim.Uri
function M.normalize(uri)
vim.validate('uri', uri, { 'string', 'table' })
local u = type(uri) == 'table' and _new(uri)
or M.parse(uri --[[@as string]])
or error(('Invalid URI: %s'):format(uri))
-- "file://localhost/a" => "file:///a" (RFC 8089 2).
if u.scheme == 'file' and u.host == 'localhost' then
u.host = ''
end
local default_port = default_ports[u.scheme]
u.port = u.port ~= default_port and u.port or nil
u.path = normalize_percent(u.path)
if u.path:find('^/') then
u.path = remove_dot_segments(u.path)
elseif u.host and u.path == '' and default_port then
-- Empty path of a "special" scheme: "http://h" => "http://h/".
u.path = '/'
end
local querystr = query_string(u)
u.querystr = querystr and normalize_percent(querystr)
u.fragment = u.fragment and normalize_percent(u.fragment)
return u
end
--- URI-encodes (percent-encodes) a string. For a querystring, use |vim.uri.encode_query()|.
---
--- Example:
---
--- ```lua
--- vim.uri.encode('/a b?') --> '/a%20b%3f'
--- vim.uri.encode('/a b?', 'rfc2396') --> '%2fa%20b%3f'
--- ```
---
---@since 15
---@param str string Arbitrary string.
---@param rfc? "rfc2396"|"rfc2732"|"rfc3986" Chars to keep:
--- - "rfc2396": only `A-Za-z0-9-_.!~*'()`.
--- - "rfc2732": "rfc2396" plus `[` and `]`.
--- - "rfc3986" (default): those valid in a path, e.g. "/", ":", "@", "&", "=".
---@return string # URI-encoded string.
function M.encode(str, rfc)
local pattern = PATTERNS[rfc] or PATTERNS.rfc3986
return (str:gsub('([' .. pattern .. '])', percent_encode_char)) -- clamped to 1 retval with ()
-- TODO(justinmk): Uppercase hex (RFC 3986 2.1), like `encode_authority_part()`? Would change
-- `from_fname()` URIs of non-ASCII paths sent to LSP servers.
return (
str:gsub(('[%s]'):format(pattern), function(c)
return ('%%%02x'):format(sbyte(c))
end)
)
end
---Decodes a URI-encoded string.
--- Encodes `params` as a querystring (`application/x-www-form-urlencoded`, as HTML forms send it):
--- space is "+", and a list value repeats its key. Sorts the keys.
---
--- Example:
---
--- ```lua
--- vim.uri.encode_query({ q = 'a b&c', tag = { 'x', 'y' }, n = 1 })
--- --> 'n=1&q=a+b%26c&tag=x&tag=y'
--- ```
---
---@since 15
---@param str string string to decode
---@return string decoded string
function M.decode(str)
return (str:gsub('%%([a-fA-F0-9][a-fA-F0-9])', hex_to_char)) -- clamped to 1 retval with ()
---@param params table<string, string|number|boolean|(string|number|boolean)[]>
---@return string
function M.encode_query(params)
vim.validate('params', params, 'table')
-- Keeps only the chars that HTML forms keep: "a b&c" => "a+b%26c".
local function encode(s)
local encoded = tostring(s):gsub('[^A-Za-z0-9*%-._ ]', function(c)
return ('%%%02X'):format(sbyte(c))
end)
return (encoded:gsub(' ', '+'))
end
local keys = vim.tbl_keys(params)
table.sort(keys, function(a, b)
return tostring(a) < tostring(b)
end)
local items = {} --- @type string[]
for _, k in ipairs(keys) do
local v = params[k]
for _, item in ipairs(type(v) == 'table' and v or { v }) do
items[#items + 1] = ('%s=%s'):format(encode(k), encode(item))
end
end
return table.concat(items, '&')
end
---Gets a URI from a file path. Relative paths are resolved to |current-directory| (RFC 8089
---disallows relative URIs).
--- Decodes a querystring (`application/x-www-form-urlencoded`, as HTML forms send it): "+" is
--- space, and a repeated key gives a list.
---
--- Example:
---
--- ```lua
--- vim.uri.decode_query('q=a+b%26c&tag=x&tag=y&flag')
--- --> { q = 'a b&c', tag = { 'x', 'y' }, flag = '' }
--- ```
---
---@since 15
---@param query string? Querystring, e.g. a form body.
---@return table<string, string|string[]>
function M.decode_query(query)
vim.validate('query', query, 'string', true)
local params = {} --- @type table<string, string|string[]>
for pair in (query or ''):gmatch('[^&]+') do
pair = pair:gsub('%+', ' ')
local eq_i = pair:find('=', 1, true)
local k = M.decode(pair:sub(1, (eq_i or 0) - 1))
local v = M.decode(eq_i and pair:sub(eq_i + 1) or '')
local prev = params[k]
if prev == nil then
params[k] = v
elseif type(prev) == 'table' then
prev[#prev + 1] = v
else
params[k] = { prev, v }
end
end
return params
end
---Decodes a URI-encoded string. (Use |vim.uri.parse()| to get the structured form.)
---@since 15
---@param str string URI-encoded string.
---@return string # Decoded string.
function M.decode(str)
return (str:gsub('%%(%x%x)', function(hex)
return schar(vim._assert_integer(hex, 16))
end))
end
---Gets a URI from a file path, or returns `path` if already a URI. Relative paths are resolved to
---|current-directory| (RFC 8089 disallows relative URIs).
---
---Example:
---
@@ -128,12 +497,16 @@ end
---vim.uri.from_fname('C:\\a\\b') --> 'file:///C:/a/b'
---vim.uri.from_fname('//server/share/a') --> 'file://server/share/a' (UNC)
---vim.uri.from_fname('a') --> 'file:///home/user/a' (if CWD is "/home/user")
---vim.uri.from_fname('jdt://a/b') --> 'jdt://a/b'
---```
---
---@since 15
---@param path string Path to file
---@return string URI
function M.from_fname(path)
if M.parse(path) then
return path
end
if iswin or path:find('^%a:') then
path = path:gsub('\\', '/') ---@type string
end
@@ -142,13 +515,11 @@ function M.from_fname(path)
end
local host = path:match('^//([^/]+)') ---@type string?
if host then -- UNC path.
return ('file://%s%s'):format(M.encode(host), M.encode(path:sub(#host + 3)))
path = path:sub(#host + 3)
elseif path:find('^%a:') then -- Windows drive letter.
path = ('/%s'):format(path)
end
local drive = path:match('^%a:') ---@type string?
if drive then -- Windows drive letter.
return ('file:///%s%s'):format(drive, M.encode(path:sub(3)))
end
return ('file://%s'):format(M.encode(path))
return tostring(_new({ scheme = 'file', host = host or '', path = M.encode(path) }))
end
---Gets a URI from a buffer.
@@ -156,11 +527,7 @@ end
---@param buf integer
---@return string URI
function M.from_buf(buf)
local fname = vim.api.nvim_buf_get_name(buf)
if M.parse(fname) then
return fname
end
return M.from_fname(fname)
return M.from_fname(vim.api.nvim_buf_get_name(buf))
end
---Gets a filename from a URI. Drops the query and fragment, so "?" and "#" in a filename must be
@@ -176,30 +543,29 @@ end
---```
---
---@since 15
---@param uri string
---@param uri string|vim.Uri
---@return string filename or unchanged URI for non-file URIs
function M.to_fname(uri)
local parts = M.parse(uri)
if not parts and uri:find('^%a:') then -- Drive letter: already a filename.
return uri
local parts = type(uri) == 'table' and uri or M.parse(uri --[[@as string]])
if not parts then
local str = uri --[[@as string]]
if not str:find('^%a:') then
error(('Invalid URI: %s'):format(str))
end
return str -- Drive letter: already a filename.
end
parts = parts or error(('URI must contain a scheme: %s'):format(uri))
if parts.scheme:lower() ~= 'file' then
return uri
if parts.scheme ~= 'file' then
return tostring(uri)
end
local host = M.decode(parts.authority or '')
local host = parts.host or ''
local path = M.decode(parts.path)
if host:find('^%a:') then
-- Drive letter, not a host (like WHATWG URL): "file://C:/a" => "C:/a".
host, path = '', ('%s%s'):format(host, path)
end
-- Collapses leading slashes: "file:a" => "/a", "file:////a" => "/a".
-- TODO(justinmk): Error on a relative path ("file:a"), like Rust `Url::to_file_path()`, unless
-- LSP servers send those?
path = path:gsub('^/*', '/') ---@type string
-- TODO: On Windows, accessing "//host/…" connects to the host (and may send NTLM credentials).
-- If users report it, allowlist hosts via a new option (like VS Code "security.allowedUNCHosts").
if host ~= '' and host:lower() ~= 'localhost' then -- UNC path.
if host ~= '' and host ~= 'localhost' then -- UNC path.
return ('//%s%s'):format(host, path)
end
-- Drive letter: "/C:/a" => "C:/a".
@@ -209,38 +575,10 @@ end
---Gets the buffer for a uri.
---Creates a new unloaded buffer if no buffer for the uri already exists.
---@since 15
---@param uri string
---@param uri string|vim.Uri
---@return integer buf
function M.to_buf(uri)
return vim.fn.bufadd(M.to_fname(uri))
end
---@class vim.net.SshUri
---@nodoc
---@field host string
---@field user? string
---@field port? string
--- Splits `ssh://[user@]host[:port][/path]` or `[user@]host[:port]`. Drops the path, and the
--- brackets of an IPv6 host: `ssh://u@[::1]:22/` => `{ user = 'u', host = '::1', port = '22' }`.
--- Percent-decodes the user and host: `ssh://a%40b@h` => `{ user = 'a@b', host = 'h' }`. Fails if
--- the host is empty.
---@param str string
---@return vim.net.SshUri
function M._parse_ssh_uri(str)
local parsed = M.parse(str)
local authority = parsed and parsed.authority or str
local user, host = authority:match('^(.*)@(.*)$') --- @type string?, string?
host = host or authority
local port = host:match(':(%d+)$') --- @type string?
if port then
host = host:sub(1, -#port - 2)
end
host = M.decode(host:match('^%[(.*)%]$') or host)
if host == '' then
error(('No host in SSH URI: %s'):format(str), 0)
end
return { host = host, user = user and M.decode(user), port = port }
end
return M
+21 -6
View File
@@ -33,6 +33,7 @@ describe('luaeval(vim.api.…)', function()
end)
end)
end)
describe('with errors', function()
it('transforms API error from nvim_buf_set_lines into lua error', function()
fn.setline(1, { 'abc', 'def', 'a\nb', 'ttt' })
@@ -65,7 +66,7 @@ describe('luaeval(vim.api.…)', function()
)
end)
it('correctly evaluates API code which calls luaeval', function()
it('evaluates API code which calls luaeval', function()
local str = (
([===[vim.api.nvim_eval([==[
luaeval('vim.api.nvim_eval([=[
@@ -78,7 +79,7 @@ describe('luaeval(vim.api.…)', function()
eq(1, fn.luaeval(str))
end)
it('correctly converts from API objects', function()
it('converts from API objects', function()
eq(1, fn.luaeval('vim.api.nvim_eval("1")'))
eq('1', fn.luaeval([[vim.api.nvim_eval('"1"')]]))
eq('Blobby', fn.luaeval('vim.api.nvim_eval("0z426c6f626279")'))
@@ -110,7 +111,7 @@ describe('luaeval(vim.api.…)', function()
eq({ { 42 }, {} }, fn.luaeval([=[vim.api.nvim_eval('[[42], []]')]=]))
end)
it('correctly converts to API objects', function()
it('converts to API objects', function()
eq(1, fn.luaeval('vim.api.nvim__id(1)'))
eq('1', fn.luaeval('vim.api.nvim__id("1")'))
eq({ 1 }, fn.luaeval('vim.api.nvim__id({1})'))
@@ -145,6 +146,20 @@ describe('luaeval(vim.api.…)', function()
fn.luaeval('vim.api.nvim__id({foo=1, bar={42, {{baz=true}, 5}}})')
)
-- Drops a metatable (e.g. `__index`, `__tostring`): converts only the table's own fields.
eq(
{ { foo = 1 }, true },
exec_lua(function()
local tbl = setmetatable({ foo = 1 }, {
__index = { bar = 2 },
__tostring = function()
return 'x'
end,
})
return { tbl, getmetatable(vim.api.nvim__id(tbl)) == nil }
end)
)
eq(true, fn.luaeval('vim.api.nvim__id(vim.api.nvim__id)(true)'))
eq(
42,
@@ -155,7 +170,7 @@ describe('luaeval(vim.api.…)', function()
)
end)
it('correctly converts container objects with type_idx to API objects', function()
it('converts container objects with type_idx to API objects', function()
eq(
5,
eval('type(luaeval("vim.api.nvim__id({[vim.type_idx]=vim.types.float, [vim.val_idx]=0})"))')
@@ -192,7 +207,7 @@ describe('luaeval(vim.api.…)', function()
)
end)
it('correctly converts arrays with type_idx to API objects', function()
it('converts arrays with type_idx to API objects', function()
eq(3, eval([[type(luaeval('vim.api.nvim__id_array({[vim.type_idx]=vim.types.array})'))]]))
eq({}, fn.luaeval('vim.api.nvim__id_array({[vim.type_idx]=vim.types.array})'))
@@ -226,7 +241,7 @@ describe('luaeval(vim.api.…)', function()
eq(3, eval([[type(luaeval('vim.api.nvim__id_array({})'))]]))
end)
it('correctly converts dictionaries with type_idx to API objects', function()
it('converts dictionaries with type_idx to API objects', function()
eq(4, eval([[type(luaeval('vim.api.nvim__id_dict({[vim.type_idx]=vim.types.dictionary})'))]]))
eq({}, fn.luaeval('vim.api.nvim__id_dict({[vim.type_idx]=vim.types.dictionary})'))
+23 -16
View File
@@ -207,21 +207,21 @@ describe('vim.net._ssh', function()
table.insert(progress, ('%s: %s'):format(ev.data.status, table.concat(ev.data.text)))
end,
})
local sock, teardown = require('vim.net._ssh').connect('user@test-server')
local sock, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
return { sock, logfile, progress }
]]))
assert(sock:match('_remote_nvim%.sock$'))
eq({
'running: Connecting to user@test-server...',
'running: Connecting to ssh://user@test-server...',
'running: Starting remote Nvim...',
'success: Connected to user@test-server',
'success: Connected to ssh://user@test-server',
}, progress)
local msg = 'This will install Nvim on user@test-server (in ~/.local/share/nvim-remote/), '
local msg = 'This will install Nvim on ssh://user@test-server (in ~/.local/share/nvim-remote/), '
.. 'if missing.\n'
eq({ { msg, 'Connect? &Yes\n&No', 1, 'Question' } }, n.exec_lua('return _G.confirm_log'))
t.matches('Connected to user@test%-server', t.read_file(logfile))
t.matches('Connected to ssh://user@test%-server', t.read_file(logfile))
local tunnel_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches('BatchMode=yes', tunnel_args)
t.matches('RemoteCommand=none', tunnel_args)
@@ -233,13 +233,14 @@ describe('vim.net._ssh', function()
n.poke_eventloop()
n.assert_alive()
-- Explicit ssh_config values are kept.
-- Explicit ssh_config values are kept. The target is decoded, with an IPv6 host unbracketed.
setup_fake_ssh({ ssh_config = 'stricthostkeychecking yes\nserveraliveinterval 60\n' })
n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('user@test-server')
local _, teardown = require('vim.net._ssh').connect('ssh://a%40b@[::1]:2222/x')
teardown()
end)
local explicit_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches(' %-p 2222 %-%- a@b@::1 sh ', explicit_args)
eq(nil, explicit_args:find('StrictHostKeyChecking', 1, true))
eq(nil, explicit_args:find('ServerAliveInterval', 1, true))
@@ -247,7 +248,7 @@ describe('vim.net._ssh', function()
setup_fake_ssh()
t.matches(
'UI detached: channel 999',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('user@test-server', 999)]])
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server', 999)]])
)
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
@@ -261,7 +262,7 @@ describe('vim.net._ssh', function()
os.remove(logfile)
-- Connects, then ends the session as the remote does: its Nvim quits.
local session = [[
local sock = require('vim.net._ssh').connect('user@test-server')
local sock = require('vim.net._ssh').connect('ssh://user@test-server')
vim.rpcnotify(vim.fn.sockconnect('pipe', sock, { rpc = true }), 'nvim_command', 'qall!')
]]
@@ -280,7 +281,7 @@ describe('vim.net._ssh', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local base, prerelease = unpack(n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('user@test-server')
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
local v = vim.version()
return { ('v%d.%d.%d'):format(v.major, v.minor, v.patch), v.prerelease and true or false }
@@ -371,9 +372,12 @@ describe('vim.net._ssh', function()
end)
t.matches(
'SSH tunnel exited before the remote Nvim was ready: channel 0: open failed',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('user@test-server')]])
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(
'failed: Failed to connect to ssh://user@test-server',
n.exec_lua('return _G.last_progress')
)
eq('failed: Failed to connect to user@test-server', n.exec_lua('return _G.last_progress'))
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
@@ -383,7 +387,11 @@ describe('vim.net._ssh', function()
})
t.matches(
'Remote Nvim crashed during startup',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('user@test-server')]])
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(
'No host in SSH URI: ssh:///x',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh:///x')]])
)
-- The script runs, but sshd refused the forward.
@@ -392,7 +400,7 @@ describe('vim.net._ssh', function()
})
t.matches(
'Remote Nvim is not reachable through the SSH forward',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('user@test-server')]])
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
@@ -402,11 +410,10 @@ describe('vim.net._ssh', function()
setup_fake_ssh()
local function remote_dir()
n.exec_lua([[
local _, teardown = require('vim.net._ssh').connect('user@test-server')
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
]])
local args = t.read_file(fake_dir .. '/tunnel.args')
t.matches('mkdir %-m 700 "%$dir"', args)
return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')
end
+171 -58
View File
@@ -59,8 +59,15 @@ describe('vim.uri', function()
end)
it('roundtrip to_fname(from_fname(path))', function()
local paths =
{ '/a/c#/b?c', '/a/%25/%A0.txt', '/a b/#@file@#', 'C:/a b/c#', '//server/share/a b' }
local paths = {
'/a/c#/b?c',
'/a/%25/%A0.txt',
'/a b/#@file@#',
'C:/a b/c#',
'//server/share/a b',
-- A URI passes through both.
'jdt://a/b?c',
}
eq(
paths,
exec_lua(function()
@@ -82,7 +89,7 @@ describe('vim.uri', function()
exec_lua("uri = 'file:/Foo/Bar/Baz.txt'")
eq('/Foo/Bar/Baz.txt', exec_lua('return vim.uri.to_fname(uri)'))
eq(
{ '/Foo/Bar', '/Foo/Bar', '/Foo/Bar', '/', '/Foo' },
{ '/Foo/Bar', '/Foo/Bar', '/Foo/Bar', '/', '/Foo', '/Foo', 'jdt://a/b' },
exec_lua(function()
local uris = {
'file:Foo/Bar',
@@ -90,6 +97,8 @@ describe('vim.uri', function()
'file://localhost/Foo/Bar',
'file://',
'FILE:///Foo',
vim.uri.parse('file:///Foo'),
vim.uri.parse('jdt://a/b'),
}
return vim.tbl_map(vim.uri.to_fname, uris)
end)
@@ -208,6 +217,9 @@ describe('vim.uri', function()
return pcall(vim.uri.to_fname, 'not_an_uri.txt')
]]
)
-- Invalid authority.
eq(false, exec_lua([[return pcall(vim.uri.to_fname, 'https://h:x')]]))
eq(false, exec_lua([[return pcall(vim.uri.to_fname, 'file://h:x/a')]]))
end)
it('does not treat comma as a scheme character', function()
@@ -323,97 +335,198 @@ describe('vim.uri', function()
it('parse()', function()
local uris = {
'https://u@example.com:80/a%20b?q=1#f?#',
'ssh://a%40b:p%3Aw@[::1]:22',
'ssh://HOST:/',
'file:///a',
'deno:/https/a',
'mailto:a@example.com',
'MailTo:a@example.com',
'inmemory:',
'foo:api/files',
'f3ile:#d',
'file:////shares/p',
'file://C:/a',
'stuff:?',
'jdt://contents/A.class?=a/%5C/b+c',
'http://[fe80::1%25en0]:8080/',
'C:/a',
'/a',
-- Invalid.
'https://h:x',
'http://::1/',
'http://[::1/',
'http://[zz]/',
'http://h:65536/',
'http://a b/',
'http://a%2@h/',
}
local parsed = exec_lua(function()
-- Roundtrip: tostring() gives the original URI, or its normalized form.
local normal = {
['ssh://HOST:/'] = 'ssh://host/',
['MailTo:a@example.com'] = 'mailto:a@example.com',
['file://C:/a'] = 'file:///C:/a',
}
return vim.tbl_map(function(uri)
local u = vim.uri.parse(uri)
-- Roundtrip: tostring() gives the original URI.
assert(not u or tostring(u) == uri, uri)
assert(not u or tostring(u) == (normal[uri] or uri), ('%s => %s'):format(uri, tostring(u)))
return u or {}
end, uris)
end)
eq({
{
scheme = 'https',
authority = 'u@example.com:80',
user = 'u',
host = 'example.com',
port = 80,
path = '/a%20b',
query = 'q=1',
query = { q = '1' },
querystr = 'q=1',
fragment = 'f?#',
},
{ scheme = 'file', authority = '', path = '/a' },
{
scheme = 'ssh',
user = 'a@b',
password = 'p:w',
host = '::1',
port = 22,
path = '',
},
{ scheme = 'ssh', host = 'host', path = '/' },
{ scheme = 'file', host = '', path = '/a' },
{ scheme = 'deno', path = '/https/a' },
{ scheme = 'mailto', path = 'a@example.com' },
{ scheme = 'inmemory', path = '' },
{ scheme = 'foo', path = 'api/files' },
{ scheme = 'f3ile', path = '', fragment = 'd' },
{ scheme = 'file', authority = '', path = '//shares/p' },
{ scheme = 'stuff', path = '', query = '' },
{ scheme = 'file', host = '', path = '//shares/p' },
{ scheme = 'file', host = '', path = '/C:/a' },
{ scheme = 'stuff', path = '', query = {}, querystr = '' },
{
scheme = 'jdt',
host = 'contents',
path = '/A.class',
query = { [''] = 'a/\\/b c' },
querystr = '=a/%5C/b+c',
},
{ scheme = 'http', host = 'fe80::1%en0', port = 8080, path = '/' },
{},
{},
{},
{},
{},
{},
{},
{},
{},
}, parsed)
end)
describe('SSH URI parser', function()
it('parses ssh://user@host:port', function()
local uri = exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://root@localhost:2222')
it('normalize()', function()
eq(
{
'https://User@example.com/a/c/~%2F?q=~%7B#~',
'http://h/',
'mailto:a/../b',
'file:///b',
true,
false,
},
exec_lua(function()
return {
tostring(
vim.uri.normalize('HTTPS://User@Example.COM:443/a/./b/../c/%7e%2f?q=%7e%7b#%7e')
),
tostring(vim.uri.normalize('http://h:80')),
tostring(vim.uri.normalize('mailto:a/../b')),
tostring(vim.uri.normalize('file://LocalHost/a/../b')),
vim.uri.normalize('HTTP://h/%7E') == vim.uri.normalize(vim.uri.parse('http://h:80/~')),
(pcall(vim.uri.normalize, 'a/b')),
}
end)
eq('root', uri.user)
eq('localhost', uri.host)
eq('2222', uri.port)
)
end)
eq(
{ user = 'u', host = '::1', port = '22' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://u@[::1]:22/home/u')
end)
)
eq(
{ host = 'host' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://host/')
end)
)
eq(
{ user = 'first.last@corp', host = 'host' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://first.last%40corp@host')
end)
)
eq(
'No host in SSH URI: ssh:///x',
t.pcall_err(exec_lua, function()
return require('vim.uri')._parse_ssh_uri('ssh:///x')
end)
)
end)
it('parses user@host', function()
local uri = exec_lua(function()
return require('vim.uri')._parse_ssh_uri('admin@server.local')
it('join()', function()
eq(
{
'file:///home/u/proj/src/a%231.lua',
'file:///home/u/proj/src/',
'file:///C:/x',
'file:///x',
'https://h/api/users/a%20b?v=1',
'https://h/a',
{ 'file:///a/b', 'file:///a' },
{ false, 'file:///a/ab:c' },
false,
},
exec_lua(function()
local base = assert(vim.uri.parse('file:///a'))
return {
tostring(vim.uri.join('file:///home/u/proj', 'src', 'a#1.lua')),
tostring(vim.uri.join('file:///home/u/proj/', 'src/')),
-- Not above the drive letter, or the root.
tostring(vim.uri.join('file:///C:/a', '../../x')),
tostring(vim.uri.join('file:///a', '../../x')),
tostring(vim.uri.join('https://h/api/?v=1', 'users', 'a b')),
tostring(vim.uri.join('https://h', 'a')),
-- Does not modify a `vim.Uri` base.
{ tostring(vim.uri.join(base, 'b')), tostring(base) },
-- A segment can't be a URI, but can contain ":".
{
(pcall(vim.uri.join, 'file:///a', 'file:///b')),
tostring(vim.uri.join('file:///a', 'ab:c')),
},
(pcall(vim.uri.join, 'a/b', 'c')),
}
end)
eq('admin', uri.user)
eq('server.local', uri.host)
eq(nil, uri.port)
end)
)
end)
it('parses host only', function()
local uri = exec_lua(function()
return require('vim.uri')._parse_ssh_uri('myalias')
it('encode_query(), decode_query()', function()
eq(
{
'n=1&q=a+b%26c%3Dd&tag=x&tag=y',
{ q = 'a b&c=d', tag = { 'x', 'y' }, n = '1', flag = '' },
},
exec_lua(function()
local query = vim.uri.encode_query({ q = 'a b&c=d', tag = { 'x', 'y' }, n = 1 })
return { query, vim.uri.decode_query(('%s&flag'):format(query)) }
end)
eq(nil, uri.user)
eq('myalias', uri.host)
eq(nil, uri.port)
end)
)
end)
it('new(), tostring()', function()
-- tostring() builds the URI from the fields.
eq(
{
'https://a%40b:p%3Aw@[::1]:8080/a',
true,
'https://[::1]:8080/a',
'https://h/s?n=1&q=a+b',
'https://h/?page=2&q=1',
'x:',
false,
},
exec_lua(function()
local u = assert(vim.uri.parse('https://example.com/a'))
u.user, u.password, u.host, u.port = 'a@b', 'p:w', '::1', 8080
return {
tostring(u),
vim.uri.parse('HTTP://h/a') == vim.uri.new({ scheme = 'http', host = 'h', path = '/a' }),
tostring(vim.uri.new({ scheme = 'HTTPS', host = '::1', port = 8080, path = '/a' })),
tostring(
vim.uri.new({ scheme = 'https', host = 'h', path = '/s', query = { q = 'a b', n = 1 } })
),
(function()
-- `querystr` takes precedence over `query`.
local q = assert(vim.uri.parse('https://h/?q=1'))
q.query.page, q.querystr = '2', nil
return tostring(q)
end)(),
tostring(vim.uri.new({ scheme = 'x' })),
(pcall(vim.uri.new, {})),
}
end)
)
end)
end)
+19 -3
View File
@@ -34,15 +34,31 @@ describe(':checkhealth', function()
eq("Invalid 'runtimepath'", string.match(err, 'Invalid.*'))
end)
it('detects invalid $VIM', function()
clear()
it('vim.health report', function()
-- Invalid $TERM.
clear({ env = { TERM = 'a&b+c' } })
exec_lua([[require('vim.health.health')._skip_update_check = true]])
-- Do this after startup, otherwise it just breaks $VIMRUNTIME.
-- Invalid $VIM. Do this after startup, otherwise it just breaks $VIMRUNTIME.
command("let $VIM='zub'")
command('checkhealth vim.health')
retry(nil, 10000, function()
eq('function', exec_lua('return type(_G.nvim_health_bugreport_open)'))
matches('ERROR $VIM .* zub', curbuf_contents())
end)
-- All sections ran.
eq(nil, curbuf_contents():match('Failed to run healthcheck.*'))
-- Bug-report URL-encodes the report.
local query = exec_lua(function()
local url --- @type string
vim.ui.open = function(u)
url = u
end
_G.nvim_health_bugreport_open()
return assert(vim.uri.parse(url)).query
end)
eq('Bug', query.type)
matches('%$TERM environment variable: `a&b%+c`', query.body)
end)
it('getcompletion()', function()