Files
neovim/runtime/lua/vim/uri.lua
T
Justin M. Keyes 67f6f0503c 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.
2026-11-05 07:49:38 -05:00

585 lines
20 KiB
Lua

-- https://tools.ietf.org/html/rfc3986
-- https://tools.ietf.org/html/rfc2732
-- https://tools.ietf.org/html/rfc2396
-- https://tools.ietf.org/html/rfc8089
local M = {}
local sbyte = string.byte
local schar = string.char
local iswin = package.config:sub(1, 1) == '\\'
local PATTERNS = {
-- RFC 2396 https://tools.ietf.org/html/rfc2396#section-2.3
rfc2396 = "^A-Za-z0-9%-_.!~*'()",
-- RFC 2732 https://tools.ietf.org/html/rfc2732
rfc2732 = "^A-Za-z0-9%-_.!~*'()%[%]",
-- 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+.-]+'
--- Encodes a user, password, or host: keeps only `authority_chars`. Uses uppercase hex (RFC 3986
--- 2.1).
---@param s string
---@return string
local function encode_authority_part(s)
return (
s:gsub(('[^%s]'):format(authority_chars), function(c)
return ('%%%02X'):format(sbyte(c))
end)
)
end
--- 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,
authority and ('//%s'):format(authority) or '',
uri.path,
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,
}
--- 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 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)
local query_i = rest:find('?', 1, true)
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?
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,
user = user,
password = password,
host = host,
port = port,
path = path,
query = query and M.decode_query(query),
querystr = query,
fragment = fragment,
} --[[@as vim.Uri]]
end
--- 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 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
-- 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
--- 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 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
--- 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:
---
---```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'
---```
---
---@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
if path ~= '' and not path:find('^/') and not path:find('^%a:') then
path = vim.fs.normalize(vim.fs.abspath(path, { plain = true }), { plain = true }) -- Force absolute, RFC 8089 disallows relative.
end
local host = path:match('^//([^/]+)') ---@type string?
if host then -- UNC path.
path = path:sub(#host + 3)
elseif path:find('^%a:') then -- Windows drive letter.
path = ('/%s'):format(path)
end
return tostring(_new({ scheme = 'file', host = host or '', path = M.encode(path) }))
end
---Gets a URI from a buffer.
---@since 15
---@param buf integer
---@return string URI
function M.from_buf(buf)
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
---percent-encoded.
---
---Example:
---
---```lua
---vim.uri.to_fname('file:///a%3Fb?c#d') --> '/a?b'
---vim.uri.to_fname('file:///C:/a/b') --> 'C:/a/b'
---vim.uri.to_fname('file://server/share/a') --> '//server/share/a'
---vim.uri.to_fname('jdt://a/b') --> 'jdt://a/b'
---```
---
---@since 15
---@param uri string|vim.Uri
---@return string filename or unchanged URI for non-file URIs
function M.to_fname(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
if parts.scheme ~= 'file' then
return tostring(uri)
end
local host = parts.host or ''
local path = M.decode(parts.path)
-- 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 ~= 'localhost' then -- UNC path.
return ('//%s%s'):format(host, path)
end
-- Drive letter: "/C:/a" => "C:/a".
return path:match('^/(%a:.*)$') or path
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|vim.Uri
---@return integer buf
function M.to_buf(uri)
return vim.fn.bufadd(M.to_fname(uri))
end
return M