local config = require("nvim_http.config") local M = {} local http_ns = vim.api.nvim_create_namespace("nvim_http") local result_prompt_bufnr = nil M.options = config.merge() function M.setup(opts) M.options = config.merge(opts) if M.options.http.enabled and M.options.http.execute_keymap and M.options.http.execute_keymap ~= "" then vim.keymap.set("n", M.options.http.execute_keymap, M.run_http_under_cursor, { desc = "Run HTTP call under cursor", silent = true, }) end if M.options.http.enabled and M.options.http.clear_keymap and M.options.http.clear_keymap ~= "" then vim.keymap.set("n", M.options.http.clear_keymap, M.clear_http_result, { desc = "Clear HTTP call output", silent = true, }) end end function M.clear_http_result() local bufnr = vim.api.nvim_get_current_buf() vim.api.nvim_buf_clear_namespace(bufnr, http_ns, 0, -1) if result_prompt_bufnr and vim.api.nvim_buf_is_valid(result_prompt_bufnr) then local ok_actions, actions = pcall(require, "telescope.actions") if ok_actions then pcall(actions.close, result_prompt_bufnr) end end result_prompt_bufnr = nil end local function trim(text) return text:match("^%s*(.-)%s*$") end local function strip_markdown_prefixes(line) local cleaned = trim(line) cleaned = cleaned:gsub("^[-*+]%s+", "") cleaned = cleaned:gsub("^%d+[.)]%s+", "") cleaned = cleaned:gsub("^>%s*", "") cleaned = cleaned:gsub("^`+", "") cleaned = cleaned:gsub("`+$", "") return trim(cleaned) end local function normalize_http_command(line) local command = strip_markdown_prefixes(line) if command == "" or command:match("^```") then return nil, "no command found on this line" end local method, url = command:match("^(%u+)%s+(https?://%S+)$") if method and url then return string.format("curl -sS -X %s %s", method, vim.fn.shellescape(url)) end if command:match("^https?://") then return string.format("curl -sS %s", vim.fn.shellescape(command)) end for _, pattern in ipairs(M.options.http.command_patterns or {}) do if command:match(pattern) then return command end end return nil, "line is not an HTTP command" end local function leading_spaces(text) local _, finish = text:find("^%s*") return finish or 0 end local function block_kind_from_key(key) local lowered = key:lower() if lowered == "header" or lowered == "headers" then return "headers" end if lowered == "body" or lowered == "request" then return "body" end return nil end local function decode_yaml_block(block_lines, root_key) local ok_lyaml, lyaml = pcall(require, "lyaml") if not ok_lyaml then return nil, "lyaml is not installed" end local ok, decoded = pcall(lyaml.load, table.concat(block_lines, "\n")) if not ok then return nil, "invalid yaml block under request line: " .. tostring(decoded) end local doc = decoded if type(decoded) == "table" and decoded[root_key] == nil and type(decoded[1]) == "table" then doc = decoded[1] end if type(doc) ~= "table" or doc[root_key] == nil then return nil, "invalid yaml block under request line" end return doc[root_key], nil end local function parse_block(lines, start_idx) local block_lines = { lines[start_idx] } local base_indent = leading_spaces(lines[start_idx]) local index = start_idx + 1 while index <= #lines do local line = lines[index] local trimmed = trim(line) if trimmed == "" then block_lines[#block_lines + 1] = line index = index + 1 else local indent = leading_spaces(line) if indent <= base_indent then break end block_lines[#block_lines + 1] = line index = index + 1 end end return block_lines, index end local function parse_request_blocks(bufnr, row) local line_count = vim.api.nvim_buf_line_count(bufnr) local start_line = row + 1 if start_line >= line_count then return { headers = nil, body = nil } end local end_line = math.min(line_count, start_line + 80) local lines = vim.api.nvim_buf_get_lines(bufnr, start_line, end_line, false) local idx = 1 while idx <= #lines and trim(lines[idx]) == "" do idx = idx + 1 end local blocks = { headers = nil, body = nil } local parse_error = nil local found_any = false while idx <= #lines do local line = lines[idx] local trimmed = trim(line) if trimmed == "" then idx = idx + 1 else local key = trimmed:match("^([%w_-]+)%s*:") if not key then break end local kind = block_kind_from_key(key) if not kind then if found_any then break end return blocks end if blocks[kind] ~= nil then break end local block_lines, next_idx = parse_block(lines, idx) local value, err = decode_yaml_block(block_lines, key) if err then parse_error = err vim.notify(err, vim.log.levels.WARN) break end blocks[kind] = value found_any = true idx = next_idx if blocks.headers ~= nil and blocks.body ~= nil then break end end end blocks.parse_error = parse_error return blocks end local function is_array(tbl) if type(tbl) ~= "table" then return false end local count = 0 for key, _ in pairs(tbl) do if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then return false end count = count + 1 end return count == #tbl end local function headers_to_list(headers) if type(headers) == "string" then return { headers } end if type(headers) ~= "table" then return {} end local list = {} if is_array(headers) then for _, value in ipairs(headers) do if type(value) == "string" then list[#list + 1] = value elseif type(value) == "table" then for key, nested in pairs(value) do list[#list + 1] = string.format("%s: %s", tostring(key), tostring(nested)) end end end else local keys = vim.tbl_keys(headers) table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) for _, key in ipairs(keys) do list[#list + 1] = string.format("%s: %s", tostring(key), tostring(headers[key])) end end return list end local function body_to_payload(body) if body == nil or body == vim.NIL then return "" end if type(body) == "table" then return vim.json.encode(body) end if type(body) == "string" then return body end return tostring(body) end local function extract_request_url(command) local quoted = command:match("'(https?://[^']+)'") or command:match('"(https?://[^"]+)"') if quoted then return quoted end local bare = command:match("(https?://%S+)") return bare or "" end local function apply_request_blocks_to_command(command, blocks) local request_details = { url = extract_request_url(command), headers = {}, body = "", parse_error = blocks and blocks.parse_error or nil, } if not blocks then return command, request_details end local header_list = headers_to_list(blocks.headers) local body_payload = body_to_payload(blocks.body) request_details.headers = header_list request_details.body = body_payload if #header_list == 0 and body_payload == "" then return command, request_details end if command:match("^curl%s+") == nil then vim.notify("headers/body blocks are currently supported only for curl-style requests", vim.log.levels.WARN) return command, request_details end local parts = {} for _, header in ipairs(header_list) do parts[#parts + 1] = "-H " .. vim.fn.shellescape(header) end if body_payload ~= "" then local has_content_type = false for _, header in ipairs(header_list) do if header:lower():match("^%s*content%-type%s*:") then has_content_type = true break end end if not has_content_type and type(blocks.body) == "table" then local content_type = "Content-Type: application/json" parts[#parts + 1] = "-H " .. vim.fn.shellescape(content_type) request_details.headers[#request_details.headers + 1] = content_type end parts[#parts + 1] = "--data-binary " .. vim.fn.shellescape(body_payload) end if #parts == 0 then return command, request_details end return command .. " " .. table.concat(parts, " "), request_details end local function run_command(command) local function normalize_newlines(text) if not text or text == "" then return "" end return text:gsub("\r\n", "\n"):gsub("\r", "\n") end local is_curl = command:match("^curl%s+") ~= nil if is_curl then local function read_temp_file(path) if vim.fn.filereadable(path) == 1 then return table.concat(vim.fn.readfile(path, "b"), "\n") end return "" end local headers_file = vim.fn.tempname() local body_file = vim.fn.tempname() local wrapped_command = string.format( "%s -D %s -o %s -w %s", command, vim.fn.shellescape(headers_file), vim.fn.shellescape(body_file), vim.fn.shellescape("__NVIM_HTTP_STATUS__:%{http_code}") ) if vim.system then local result = vim.system({ "sh", "-c", wrapped_command }, { text = true, timeout = M.options.http.timeout_ms, }):wait() local body = read_temp_file(body_file) local headers = read_temp_file(headers_file) vim.fn.delete(headers_file) vim.fn.delete(body_file) local status_code = nil if result.stdout and result.stdout ~= "" then status_code = result.stdout:match("__NVIM_HTTP_STATUS__:(%d%d%d)") end return { code = result.code or 0, stdout = normalize_newlines(body), stderr = normalize_newlines(result.stderr or ""), headers = normalize_newlines(headers), response_code = status_code, } end local output = vim.fn.system(wrapped_command .. " 2>&1") local code = vim.v.shell_error local body = read_temp_file(body_file) local headers = read_temp_file(headers_file) vim.fn.delete(headers_file) vim.fn.delete(body_file) local status_code = output:match("__NVIM_HTTP_STATUS__:(%d%d%d)") return { code = code, stdout = normalize_newlines(body), stderr = normalize_newlines(output:gsub("__NVIM_HTTP_STATUS__:%d%d%d", "") or ""), headers = normalize_newlines(headers), response_code = status_code, } end if vim.system then local result = vim.system({ "sh", "-c", command }, { text = true, timeout = M.options.http.timeout_ms, }):wait() return { code = result.code or 0, stdout = normalize_newlines(result.stdout or ""), stderr = normalize_newlines(result.stderr or ""), headers = "", response_code = nil, } end local output = vim.fn.system(command .. " 2>&1") return { code = vim.v.shell_error, stdout = normalize_newlines(output or ""), stderr = "", headers = "", response_code = nil, } end local function scalar_to_string(value) if type(value) == "string" then return string.format("%q", value) end if value == vim.NIL then return "null" end return tostring(value) end local function add_json_entries(entries, value, depth, label, path, expanded_nodes) local indent = string.rep(" ", depth) local value_type = type(value) if value_type ~= "table" then entries[#entries + 1] = { id = path, expandable = false, display = string.format("%s%s: %s", indent, label, scalar_to_string(value)), } return end local array = is_array(value) local count = array and #value or vim.tbl_count(value) local expanded = expanded_nodes[path] == true local marker = expanded and "▼" or "▶" local type_label = array and ("[" .. count .. "]") or ("{" .. count .. "}") entries[#entries + 1] = { id = path, expandable = true, display = string.format("%s%s %s: %s", indent, marker, label, type_label), } if not expanded then return end if array then for index, child in ipairs(value) do add_json_entries(entries, child, depth + 1, "[" .. index .. "]", path .. "/" .. index, expanded_nodes) end return end local keys = vim.tbl_keys(value) table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) for _, key in ipairs(keys) do add_json_entries(entries, value[key], depth + 1, tostring(key), path .. "/" .. tostring(key), expanded_nodes) end end local function build_picker_entries(result, state) local entries = {} entries[#entries + 1] = { id = "response_code", expandable = false, display = "response_code: " .. (result.response_code or "n/a"), } entries[#entries + 1] = { id = "", expandable = false, display = "", } local request_expanded = state.expanded_nodes["request"] == true entries[#entries + 1] = { id = "request", expandable = true, display = string.format("%s request", request_expanded and "▼" or "▶"), } if request_expanded then entries[#entries + 1] = { id = "request/url", expandable = false, display = " url: " .. ((result.request and result.request.url ~= "") and result.request.url or "(unknown)"), } if result.request and result.request.parse_error then entries[#entries + 1] = { id = "request/parse_error", expandable = false, display = " parse_error: " .. result.request.parse_error, } end local req_headers_expanded = state.expanded_nodes["request/headers"] == true entries[#entries + 1] = { id = "request/headers", expandable = true, display = string.format(" %s headers", req_headers_expanded and "▼" or "▶"), } if req_headers_expanded then local req_headers = (result.request and result.request.headers) or {} if #req_headers == 0 then entries[#entries + 1] = { id = "request/headers/empty", expandable = false, display = " (empty)" } else for idx, header in ipairs(req_headers) do entries[#entries + 1] = { id = "request/headers/" .. idx, expandable = false, display = " " .. header, } end end end local req_body_expanded = state.expanded_nodes["request/body"] == true entries[#entries + 1] = { id = "request/body", expandable = true, display = string.format(" %s body", req_body_expanded and "▼" or "▶"), } if req_body_expanded then local req_body = (result.request and result.request.body) or "" if req_body == "" then entries[#entries + 1] = { id = "request/body/empty", expandable = false, display = " (empty)" } else local ok_decode, decoded = pcall(vim.json.decode, req_body) if ok_decode and type(decoded) == "table" then add_json_entries(entries, decoded, 2, "$", "request/body/json/$", state.expanded_nodes) else for idx, line in ipairs(vim.split(req_body, "\n", { plain = true })) do entries[#entries + 1] = { id = "request/body/raw/" .. idx, expandable = false, display = " " .. line, } end end end end entries[#entries + 1] = { id = "request/separator", expandable = false, display = "", } end local headers_expanded = state.expanded_nodes["headers"] == true entries[#entries + 1] = { id = "headers", expandable = true, display = string.format("%s headers", headers_expanded and "▼" or "▶"), } if headers_expanded then local header_lines = vim.split(result.headers or "", "\n", { plain = true }) if #header_lines == 0 or (#header_lines == 1 and header_lines[1] == "") then entries[#entries + 1] = { id = "headers/empty", expandable = false, display = " (empty)" } else for idx, line in ipairs(header_lines) do if line ~= "" then entries[#entries + 1] = { id = "headers/" .. idx, expandable = false, display = " " .. line, } end end end end local result_expanded = state.expanded_nodes["result"] == true entries[#entries + 1] = { id = "result", expandable = true, display = string.format("%s result", result_expanded and "▼" or "▶"), } if result_expanded then local ok_decode, decoded = pcall(vim.json.decode, result.stdout or "") if ok_decode and type(decoded) == "table" then add_json_entries(entries, decoded, 1, "$", "result/json/$", state.expanded_nodes) else local body_lines = vim.split(result.stdout or "", "\n", { plain = true }) if #body_lines == 0 or (#body_lines == 1 and body_lines[1] == "") then entries[#entries + 1] = { id = "result/empty", expandable = false, display = " (empty)" } else for idx, line in ipairs(body_lines) do entries[#entries + 1] = { id = "result/raw/" .. idx, expandable = false, display = " " .. line, } end end end end if result.stderr and result.stderr ~= "" then local stderr_expanded = state.expanded_nodes["stderr"] == true entries[#entries + 1] = { id = "stderr", expandable = true, display = string.format("%s stderr", stderr_expanded and "▼" or "▶"), } if stderr_expanded then for idx, line in ipairs(vim.split(result.stderr, "\n", { plain = true })) do entries[#entries + 1] = { id = "stderr/" .. idx, expandable = false, display = " " .. line, } end end end return entries end local function render_result_window(result, state) local ok_telescope = pcall(require, "telescope") if not ok_telescope then vim.notify("nvim-http requires telescope.nvim to show results", vim.log.levels.ERROR) return end local pickers = require("telescope.pickers") local finders = require("telescope.finders") local conf = require("telescope.config").values local themes = require("telescope.themes") local actions = require("telescope.actions") local action_state = require("telescope.actions.state") if result_prompt_bufnr and vim.api.nvim_buf_is_valid(result_prompt_bufnr) then pcall(actions.close, result_prompt_bufnr) result_prompt_bufnr = nil end local function make_finder(entries) return finders.new_table({ results = entries, entry_maker = function(item) return { value = item, ordinal = item.display, display = item.display, } end, }) end local entries = build_picker_entries(result, state) local picker picker = pickers.new(themes.get_dropdown({ previewer = false, layout_config = { width = 0.9, height = 0.9, }, }), { prompt_title = "HTTP Result", results_title = "response tree", selection_strategy = "row", finder = make_finder(entries), sorter = conf.generic_sorter({}), attach_mappings = function(prompt_bufnr, map) result_prompt_bufnr = prompt_bufnr local function toggle_expand() local current_picker = action_state.get_current_picker(prompt_bufnr) local selection = action_state.get_selected_entry() if not selection or not selection.value or not selection.value.expandable then return end local current_row = current_picker:get_selection_row() local id = selection.value.id state.expanded_nodes[id] = not state.expanded_nodes[id] local updated_entries = build_picker_entries(result, state) local target_row = current_row for idx, entry in ipairs(updated_entries) do if entry.id == id then target_row = idx - 1 break end end current_picker:refresh(make_finder(updated_entries), { reset_prompt = false }) vim.defer_fn(function() pcall(current_picker.set_selection, current_picker, target_row) end, 10) end map("i", "", toggle_expand) map("n", "", toggle_expand) map("i", "", toggle_expand) map("n", "", toggle_expand) map("i", "q", actions.close) map("n", "q", actions.close) return true end, }) picker:find() end function M.run_http_under_cursor() if not M.options.http.enabled then vim.notify("http runner is disabled", vim.log.levels.WARN) return end local bufnr = vim.api.nvim_get_current_buf() local cursor = vim.api.nvim_win_get_cursor(0) local row = cursor[1] - 1 local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or "" local command, err = normalize_http_command(line) if not command then vim.notify(err, vim.log.levels.WARN) return end local request_blocks = parse_request_blocks(bufnr, row) local request_info command, request_info = apply_request_blocks_to_command(command, request_blocks) local result = run_command(command) result.request = request_info or { url = extract_request_url(command), headers = {}, body = "", } vim.api.nvim_buf_clear_namespace(bufnr, http_ns, 0, -1) render_result_window(result, { expanded_nodes = {} }) end return M