Start manual rewrite

This commit is contained in:
Radu Macocian (admac)
2026-03-17 10:13:24 +01:00
parent 70016196c1
commit 7f7b38e394
3 changed files with 57 additions and 760 deletions

View File

@@ -1,767 +1,12 @@
local config = require("nvim_http.config")
local M = {} local M = {}
local http_ns = vim.api.nvim_create_namespace("nvim_http")
local result_prompt_bufnr = nil
M.options = config.merge() function M.get_current_line()
return vim.api.nvim_get_current_line()
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 end
function M.clear_http_result() function M.parse_line(line)
local bufnr = vim.api.nvim_get_current_buf() local a, b = line:match"^(%S+)%s+(.+)"
vim.api.nvim_buf_clear_namespace(bufnr, http_ns, 0, -1) return a, b
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", "<CR>", toggle_expand)
map("n", "<CR>", toggle_expand)
map("i", "<Tab>", toggle_expand)
map("n", "<Tab>", 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 end
return M return M

10
run_tests.sh Executable file
View File

@@ -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

42
tests/test_parse_line.lua Normal file
View File

@@ -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))