Something went wrong. Try again.
Something went wrong. Try again.
20 kB · 559 lines
Lua
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560---@mod rest-nvim.parser rest.nvim http syntax parsing module------@brief [[------ rest.nvim `.http` syntax parsing module.--- rest.nvim uses `tree-sitter-http` as a core parser for `.http` syntax------@brief ]]
local parser = {}
local Context = require("rest-nvim.context").Contextlocal utils = require("rest-nvim.utils")local logger = require("rest-nvim.logger")local jar = require("rest-nvim.cookie_jar")local nio = require("nio")
---@alias Source integer|string Buffer or string which the `node` is extracted
local NAMED_REQUEST_QUERY = vim.treesitter.query.parse( "http", [[(section (request_separator value: (_) @name) request: (_)) @request(section (comment name: (_) @_keyword value: (_) @name (#eq? @_keyword "name")) request: (_)) @request]])
---@param node TSNode---@param field string---@param source Source---@return string|nillocal function get_node_field_text(node, field, source) local n = node:field(field)[1] return n and vim.treesitter.get_node_text(n, source) or nilend
---@param src string---@param context rest.Context---@return string---@return integerlocal function expand_variables(src, context) -- remove $dotenv tags, which are used by the vscode rest client for cross compatibility src = src:gsub("%$dotenv ", ""):gsub("%$DOTENV ", "") return src:gsub("{{(.-)}}", function(name) name = vim.trim(name) local res = context:resolve(name) return res end)end
---@param req_node TSNode Tree-sitter request node---@param source Source---@param context rest.Context---@return table<string,string[]> headerslocal function parse_headers(req_node, source, context) local headers = vim.defaulttable(function() return {} end) local header_nodes = req_node:field("header") for _, node in ipairs(header_nodes) do local key = assert(get_node_field_text(node, "name", source)) local value = get_node_field_text(node, "value", source) key = expand_variables(key, context):lower() if value then value = expand_variables(value, context) table.insert(headers[key], value) else headers[key] = {} end end return setmetatable(headers, nil)end
---@param str string---@return booleanlocal function validate_json(str) local ok, _ = pcall(vim.json.decode, str) return okend
---@param str string---@return booleanlocal function validate_xml(str) local xml2lua = require("xml2lua") local handler = require("xmlhandler.tree"):new() local xml_parser = xml2lua.parser(handler) local ok = pcall(function(t) return xml_parser:parse(t) end, str) return okend
---@param str string---@return string?local function parse_urlencoded_form(str) local query_pairs = vim.split(str, "&") return vim.iter(query_pairs) :map(function(query) local key, value = query:match("([^=]+)=?(.*)") if not key then logger.error(("Error while parsing query '%s' from urlencoded form '%s'"):format(query_pairs, str)) return nil end return vim.trim(key) .. "=" .. vim.trim(value) end) :join("&")end
---@param content_type string?---@param body_node TSNode---@param source Source---@param context rest.Context---@return rest.Request.Body|nilfunction parser.parse_body(content_type, body_node, source, context) local body = {} local node_type = body_node:type() ---@cast body rest.Request.Body if node_type == "external_body" then body.__TYPE = "external" local path = assert(get_node_field_text(body_node, "path", source)) if type(source) ~= "number" then logger.error("can't parse external body on non-existing http file") return end ---@cast source integer local basepath = vim.fs.dirname(vim.api.nvim_buf_get_name(source)) ---@diagnostic disable-next-line: undefined-field basepath = basepath:gsub("^" .. vim.pesc(vim.uv.cwd() .. "/"), "") path = vim.fs.normalize(vim.fs.joinpath(basepath, path)) body.data = { name = get_node_field_text(body_node, "name", source), path = path, } local body_text = vim.treesitter.get_node_text(body_node, source) if vim.startswith(body_text, "<@") then logger.debug("external body with '<@' prefix") return body end local file_content = utils.read_file(path) file_content = expand_variables(file_content, context) body.data.content = file_content elseif node_type == "graphql_body" then body.__TYPE = "graphql" local query_text = vim.treesitter.get_node_text(assert(body_node:named_child(0)), source) query_text = expand_variables(query_text, context) local variables_text local variables_node = body_node:named_child(1) if variables_node then variables_text = vim.treesitter.get_node_text(variables_node, source) variables_text = expand_variables(variables_text, context) end body.data = vim.json.encode({ query = query_text, variables = variables_text and vim.json.decode(variables_text), }) logger.debug(body.data) elseif node_type == "json_body" or content_type == "application/json" then body.__TYPE = "json" body.data = vim.trim(vim.treesitter.get_node_text(body_node, source)) body.data = expand_variables(body.data, context) local ok = validate_json(body.data) if not ok then logger.warn("invalid json: '" .. body.data .. "'") return nil end elseif node_type == "xml_body" or content_type == "application/xml" then body.__TYPE = "xml" body.data = vim.trim(vim.treesitter.get_node_text(body_node, source)) body.data = expand_variables(body.data, context) local ok = validate_xml(body.data) if not ok then logger.warn("invalid xml: '" .. body.data .. "'") return nil end elseif node_type == "raw_body" then local text = vim.treesitter.get_node_text(body_node, source) if content_type and vim.startswith(content_type, "application/x-www-form-urlencoded") then body.__TYPE = "raw" body.data = parse_urlencoded_form(text) body.data = expand_variables(body.data, context) if not body.data then logger.error("Error while parsing urlencoded form") return nil end else body.__TYPE = "raw" body.data = text end elseif node_type == "multipart_form_data" then body.__TYPE = "multipart_form_data" -- TODO: logger.error("multipart form data is not supported yet") end return bodyend
---In-place variables can be evaluated in loaded buffers due to treesitter limitations---@param source integer---@param ctx rest.Context---@param endline number zero-based line numberfunction parser.eval_context(source, ctx, endline) vim.validate({ source = { source, "number" } }) local startline = ctx.linenr for ln = startline, endline do local start_node = vim.treesitter.get_node({ pos = { ln, 0 } }) if start_node then local node = utils.ts_find(start_node, "variable_declaration", true) if node then parser.parse_variable_declaration(node, source, ctx) end end endend
---@return TSNode? node TSNode with type `section`function parser.get_request_node_by_cursor() local node = vim.treesitter.get_node() if node then node = utils.ts_find(node, "section") if not node then logger.error("can't find request section node") return elseif node:has_error() then logger.error(utils.ts_node_error_log(node)) return elseif #node:field("request") < 1 then logger.error("request section doesn't have request node") return end end return nodeend
---@param source Source---@return TSNode[]function parser.get_all_request_nodes(source) local _, tree = utils.ts_parse_source(source) local result = {} for node, _ in tree:root():iter_children() do if node:type() == "section" and #node:field("request") > 0 then table.insert(result, node) end end return resultend
---@return TSNode?function parser.get_request_node_by_name(name) local source = 0 local _, tree = utils.ts_parse_source(source) local query = NAMED_REQUEST_QUERY for id, node, _metadata, _match in query:iter_captures(tree:root(), source) do local capture_name = query.captures[id] if capture_name == "name" and vim.treesitter.get_node_text(node, source) == name then local find = utils.ts_find(node, "section") if find then return find end end endend
---@param vd_node TSNode---@param source Source---@param ctx rest.Contextfunction parser.parse_variable_declaration(vd_node, source, ctx) vim.validate({ node = utils.ts_node_spec(vd_node, "variable_declaration") }) local name = assert(get_node_field_text(vd_node, "name", source)) local value = vim.trim(assert(get_node_field_text(vd_node, "value", source))) value = expand_variables(value, ctx) ctx:set_global(name, value)end
---@param node TSNode---@param source Source---@return string lang---@return string strlocal function parse_script(node, source) local lang = "javascript" local prev_node = utils.ts_upper_node(node) if prev_node and prev_node:type() == "comment" and get_node_field_text(prev_node, "name", source) == "lang" then local value = get_node_field_text(prev_node, "value", source) if value then lang = value end end local script_node = assert(node:named_child(0)) local str = vim.treesitter.get_node_text(script_node, source):sub(3, -3) return lang, strend
---@param node TSNode---@param source Source---@param context rest.Contextfunction parser.parse_pre_request_script(node, source, context) local lang, str = parse_script(node, source) local ok, script = pcall(require, "rest-nvim.script." .. lang) if not ok then logger.error(("failed to load script with language '%s'. Can't find script runner client."):format(lang)) return end ---@cast script rest.ScriptClient script.load_pre_req_hook(str, context)()end
---@param node TSNode---@param source Source---@param context rest.Context---@return function?function parser.parse_request_handler(node, source, context) local lang, str = parse_script(node, source) local ok, script = pcall(require, "rest-nvim.script." .. lang) if not ok then logger.error(("failed to load script with language '%s'. Can't find script runner client."):format(lang)) return end ---@cast script rest.ScriptClient return script.load_post_req_hook(str, context)end
---@param node TSNode---@param source Source---@param ctx rest.Context---@return function?function parser.parse_redirect_path(node, source, ctx) local force = vim.treesitter.get_node_text(node, source):match("^>>!") local path = get_node_field_text(node, "path", source) if path then path = expand_variables(path, ctx) return function(res) if not res.body then return end logger.debug("save response body to:", path) if not force then local suffix_idx = 1 while utils.file_exists(path) do local pathname, pathext = path:match("([^.]+)(.*)") path = ("%s_%d%s"):format(pathname, suffix_idx, pathext) suffix_idx = suffix_idx + 1 end end local respfile, openerr = io.open(path, "w+") if not respfile then local err_msg = string.format("Failed to open response file (%s): %s", path, openerr) vim.notify(err_msg, vim.log.levels.ERROR, { title = "rest.nvim" }) return end respfile:write(res.body) respfile:close() logger.debug("response body saved done") end endend
---@param source Source---@return string[]function parser.get_request_names(source) local _, tree = utils.ts_parse_source(source) local query = NAMED_REQUEST_QUERY local result = {} for id, node, _metadata, _match in query:iter_captures(tree:root(), source) do local capture_name = query.captures[id] if capture_name == "name" then table.insert(result, vim.treesitter.get_node_text(node, source)) end end return resultend
---@param name string|nil---@return TSNode?function parser.get_request_node(name) local req_node if not name then req_node = parser.get_request_node_by_cursor() if not req_node then logger.error("Failed to find request at cursor position") vim.notify( "Failed to find request at cursor position. See `:Rest logs` for more info.", vim.log.levels.ERROR, { title = "rest.nvim" } ) return end else req_node = parser.get_request_node_by_name(name) if not req_node then logger.error("Failed to find request by name: " .. name) vim.notify( "Failed to find request by name: " .. name .. ". See `:Rest logs` for more info.", vim.log.levels.ERROR, { title = "rest.nvim" } ) return end end return req_nodeend
---Parse the request node and create Request object. Returns `nil` if parsing---failed.---@param node TSNode Tree-sitter request node---@param source Source---@param ctx? rest.Context---@return rest.Request|nilfunction parser.parse(node, source, ctx) assert(node:type() == "section") assert(not node:has_error()) local req_node = node:field("request")[1] assert(req_node) if source == 0 then source = vim.api.nvim_get_current_buf() end
ctx = ctx or Context:new() -- TODO: note that in-place variables won't be evaluated for raw string due to treesitter limitations -- when source is given as raw string if type(source) == "number" then local start_row = node:range() parser.eval_context(source, ctx, start_row) end local method = get_node_field_text(req_node, "method", source) if not method then logger.info("no method provided, falling back to 'GET'") method = "GET" end if method == "GRAPHQL" then method = "POST" end -- NOTE: url will be parsed after because in-place variables should be parsed first local url
---@type string|nil local name local handlers = {} for child, _ in node:iter_children() do local child_type = child:type() if child_type == "request" then url = expand_variables(assert(get_node_field_text(req_node, "url", source)), ctx) url = url:gsub("\n%s+", "") elseif child_type == "pre_request_script" then parser.parse_pre_request_script(child, source, ctx) -- won't be a case anymore with latest tree-sitter-http parser. just for backward compatibility elseif child_type == "res_handler_script" then local handler = parser.parse_request_handler(child, source, ctx) if handler then table.insert(handlers, handler) end elseif child_type == "request_separator" then name = get_node_field_text(child, "value", source) elseif child_type == "comment" and child:field("name")[1] then local comment_name = get_node_field_text(child, "name", source) local comment_value = get_node_field_text(child, "value", source) if comment_name == "name" then name = comment_value or name elseif comment_name == "prompt" and comment_value then local var_name, var_description = comment_value:match("(%S+)%s*(.*)") if var_description == "" then var_description = nil end ---@diagnostic disable-next-line: missing-fields local input = nio.ui.input({ prompt = (var_description or ("Enter value for `%s`"):format(var_name)) .. ": ", default = ctx:resolve(var_name), }) if input then ctx:set_local(var_name, input) end end elseif child_type == "variable_declaration" then parser.parse_variable_declaration(child, source, ctx) end end for child, _ in req_node:iter_children() do local child_type = child:type() if child_type == "res_handler_script" then logger.debug("find request node child:", child_type) local handler = parser.parse_request_handler(child, source, ctx) if handler then table.insert(handlers, handler) end elseif child_type == "res_redirect" then logger.debug("find request node child:", child_type) local handler = parser.parse_redirect_path(child, source, ctx) if handler then table.insert(handlers, handler) end end end if not name then if type(source) == "number" then local filename = vim.api.nvim_buf_get_name(source) name = filename:match(".*/%.?(.*).http$") or filename name = name .. "#" .. vim.b[source]._rest_nvim_count vim.b[source]._rest_nvim_count = vim.b[source]._rest_nvim_count + 1 end end
local headers = parse_headers(req_node, source, ctx) if headers["host"] and vim.startswith(url, "/") then local host = headers["host"][1] if not host:match("^https?://") then local port = host:match(":(%d%d+)$") local protocol = "http://" if not port or port == "443" then protocol = "https://" end host = protocol .. host end url = host .. url table.remove(headers["host"], 1) end
---@type string? local content_type if headers["content-type"] and #headers["content-type"] > 0 then content_type = headers["content-type"][1]:match("([^;]+)") end local body local body_node = req_node:field("body")[1] if body_node then body = parser.parse_body(content_type, body_node, source, ctx) if not body then logger.error("parsing body failed") vim.notify( "parsing request body failed. See `:Rest logs` for more info.", vim.log.levels.ERROR, { title = "rest.nvim" } ) return nil end end
---@type rest.Request local req = { name = name, method = method, url = url, http_version = get_node_field_text(req_node, "version", source), headers = headers, cookies = {}, body = body, handlers = handlers, } ctx:clear_local() jar.load_cookies(req) return reqend
return parser