From 7f7b38e3944d4d2419d50cb491df06301b7f88ad Mon Sep 17 00:00:00 2001 From: "Radu Macocian (admac)" Date: Tue, 17 Mar 2026 10:13:24 +0100 Subject: [PATCH] Start manual rewrite --- lua/nvim_http/init.lua | 765 +------------------------------------- run_tests.sh | 10 + tests/test_parse_line.lua | 42 +++ 3 files changed, 57 insertions(+), 760 deletions(-) create mode 100755 run_tests.sh create mode 100644 tests/test_parse_line.lua diff --git a/lua/nvim_http/init.lua b/lua/nvim_http/init.lua index ab890d5..864af09 100644 --- a/lua/nvim_http/init.lua +++ b/lua/nvim_http/init.lua @@ -1,767 +1,12 @@ -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 +function M.get_current_line() + return vim.api.nvim_get_current_line() 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 = {} }) +function M.parse_line(line) + local a, b = line:match"^(%S+)%s+(.+)" + return a, b end return M - diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..67f1bf6 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Run Neovim plugin tests + +cd "$(dirname "$0")" + +for test_file in tests/test_*.lua; do + echo "Running $test_file..." + nvim --headless --noplugin -u NONE -c "set rtp+=." -l "$test_file" + echo "" +done diff --git a/tests/test_parse_line.lua b/tests/test_parse_line.lua new file mode 100644 index 0000000..13f6012 --- /dev/null +++ b/tests/test_parse_line.lua @@ -0,0 +1,42 @@ +local M = require('nvim_http') + +-- Test cases +local tests = { + {"GET https://example.com", "GET", "https://example.com"}, + {"POST /api/users", "POST", "/api/users"}, + {"DELETE /api/users/123", "DELETE", "/api/users/123"}, + {"PUT /api/users/123 body", "PUT", "/api/users/123 body"}, + {"PATCH https://api.example.com/v1/resource", "PATCH", "https://api.example.com/v1/resource"}, + {"INVALID", nil, nil}, + {"", nil, nil}, + {" ", nil, nil}, +} + +print("Testing parse_line function:") +print("=" .. string.rep("=", 70)) + +local passed = 0 +local failed = 0 + +for i, test in ipairs(tests) do + local input, expected_a, expected_b = test[1], test[2], test[3] + local a, b = M.parse_line(input) + + local success = (a == expected_a and b == expected_b) + local status = success and "✓ PASS" or "✗ FAIL" + + if success then + passed = passed + 1 + else + failed = failed + 1 + end + + print(string.format("%s Test %d: '%s'", status, i, input)) + print(string.format(" Got: a=%s, b=%s", tostring(a), tostring(b))) + if not success then + print(string.format(" Expected: a=%s, b=%s", tostring(expected_a), tostring(expected_b))) + end +end + +print("=" .. string.rep("=", 70)) +print(string.format("Results: %d passed, %d failed", passed, failed))