diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bcd2da --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Local JSONPlaceholder fixtures +/testdata/jsonplaceholder/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b699e2 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# nvim-http + +A Neovim plugin for quick HTTP execution from your notes/code buffers. + +## Current status + +Current features: + +- `setup()` configuration +- Run HTTP commands under cursor +- Optional YAML `header(s)` and `body`/`request` blocks under the request line +- Telescope response tree with expandable `headers` and nested JSON `result` +- `:NvimHttpRun` and `:NvimHttpClear` commands +- `:help nvim-http` docs + +## Install + +```lua +{ + "your-name/nvim-http", + dependencies = { + "nvim-lua/plenary.nvim", + "nvim-telescope/telescope.nvim", + }, + build = "rockspec", + config = function() + require("nvim_http").setup() + end, +} +``` + +## Usage + +Put cursor on a line like: + +```vim +curl https://httpbin.org/get +``` + +Then run: + +```vim +:NvimHttpRun +``` + +This opens a Telescope window with: +- `response_code` +- a collapsed `request` block (`url`, `headers`, `body`) +- a collapsed `headers` block +- a collapsed `result` block (nested JSON tree when response is JSON) + +Use `` or `` to expand/collapse the selected node and `q` to close. + +Default keymap: + +```vim +hr +``` + +Default clear keymap: + +```vim +hc +``` + +This also supports lines like: + +- `GET https://httpbin.org/get` +- `https://httpbin.org/get` (auto-translated to `curl -sS `) + +### YAML blocks under request line + +You can add request headers/body directly under the request line using YAML blocks: + +```text +POST https://jsonplaceholder.typicode.com/users +headers: + Content-Type: application/json + Authorization: Bearer token +body: + name: Jane + username: jane1 +``` + +Also accepted block names: +- `header` or `headers` +- `body` or `request` + +Parsing rules: +- only lines directly under the request are scanned (bounded lookahead; not whole file) +- leading whitespace is ignored +- only the first `header(s)` and first `body`/`request` block are used +- parsing stops once both blocks are found +- parsing also stops if another top-level block appears after parsing started +- request blocks are currently applied to curl-style requests +- YAML block decoding requires the `lyaml` Lua rock + +Examples: + +```text +url +header: + X-Test: 1 +body: + a: 1 +``` +uses both `header` and `body`. + +```text +url +header: + X-Test: 1 +body: + a: 1 +header2: + ignored: true +``` +stops after `body`, so `header2` is ignored. + +```text +url +header: + X-Test: 1 +header2: + nope: 1 +body: + ignored: true +``` +stops when `header2` is reached, so `body` is ignored. + +```text +url +body: + a: 1 +header: + X-Test: 1 +``` +works (body-first order is accepted). + +## Configuration + +```lua +require("nvim_http").setup({ + http = { + enabled = true, + execute_keymap = "hr", + clear_keymap = "hc", + timeout_ms = 10000, + highlight_group = "Comment", + command_patterns = { + "^curl%s+", + "^http%s+", + "^wget%s+", + "^%u+%s+https?://", + "^https?://", + }, + }, +}) +``` + +## Layout + +- `plugin/nvim-http.lua`: User command registration +- `lua/nvim_http/init.lua`: Plugin API and HTTP runner +- `lua/nvim_http/config.lua`: Defaults and config merging +- `doc/nvim-http.txt`: Help documentation diff --git a/doc/nvim-http.txt b/doc/nvim-http.txt new file mode 100644 index 0000000..79d77e2 --- /dev/null +++ b/doc/nvim-http.txt @@ -0,0 +1,148 @@ +*nvim-http.txt* nvim-http + +============================================================================== +CONTENTS *nvim-http-contents* + +1. Introduction.............................................|nvim-http-intro| +2. Setup....................................................|nvim-http-setup| +3. Commands.................................................|nvim-http-commands| +4. HTTP Runner..............................................|nvim-http-http| + +============================================================================== +INTRODUCTION *nvim-http-intro* + +nvim-http supports: +- Running HTTP commands from the current line under cursor +- Parsing optional YAML header/body blocks under the request line +- Showing command output in a Telescope tree window + +============================================================================== +SETUP *nvim-http-setup* + +Using lazy.nvim: + +>lua + { + "macocianradu/nvim-http", + dependencies = { + "nvim-lua/plenary.nvim", + "nvim-telescope/telescope.nvim", + }, + build = "rockspec", + config = function() + require("nvim_http").setup({ + http = { + enabled = true, + execute_keymap = "hr", + clear_keymap = "hc", + timeout_ms = 10000, + highlight_group = "Comment", + command_patterns = { + "^curl%s+", + "^http%s+", + "^wget%s+", + "^%u+%s+https?://", + "^https?://", + }, + }, + }) + end, + } +< + +============================================================================== +COMMANDS *nvim-http-commands* + +:NvimHttpRun + Run the HTTP command under cursor and open a Telescope window with: + - response_code + - a collapsed request block (url, headers, body) + - a collapsed headers block + - a collapsed result block (nested JSON tree when response is JSON) + +:NvimHttpClear + Close and clear the HTTP result Telescope window. + +============================================================================== +HTTP RUNNER *nvim-http-http* + +Default mapping: + hr + +Default clear mapping: + hc + +Result window usage: + or to open/close/toggle the selected node + q to close the result window + +Supported line styles: + curl https://example.com + http GET https://example.com + wget https://example.com + GET https://example.com + https://example.com + +Lines in markdown lists/quotes/inline code are normalized first, so these also +work: + - curl https://example.com + `GET https://example.com` + > https://example.com + +YAML request blocks under request line: + POST https://jsonplaceholder.typicode.com/users + headers: + Content-Type: application/json + Authorization: Bearer token + body: + name: Jane + username: jane1 + +Accepted block names: + header / headers + body / request + +Parsing behavior: + - scans only directly-below lines with bounded lookahead (does not scan file) + - ignores leading whitespace + - uses only first header(s) block and first body/request block + - stops when both are found + - also stops when another top-level block is found after parsing started + - request blocks are currently applied to curl-style requests + - YAML block decoding requires the lyaml Lua rock + +Examples: + url + header: + X-Test: 1 + body: + a: 1 + -> uses both + + url + header: + X-Test: 1 + body: + a: 1 + header2: + ignored: true + -> stops after body, ignores header2 + + url + header: + X-Test: 1 + header2: + nope: 1 + body: + ignored: true + -> stops at header2, ignores body + + url + body: + a: 1 + header: + X-Test: 1 + -> body-first order works + +============================================================================== +vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..8548fa3 --- /dev/null +++ b/doc/tags @@ -0,0 +1,6 @@ +nvim-http-commands nvim-http.txt /*nvim-http-commands* +nvim-http-contents nvim-http.txt /*nvim-http-contents* +nvim-http-http nvim-http.txt /*nvim-http-http* +nvim-http-intro nvim-http.txt /*nvim-http-intro* +nvim-http-setup nvim-http.txt /*nvim-http-setup* +nvim-http.txt nvim-http.txt /*nvim-http.txt* diff --git a/lua/nvim_http/config.lua b/lua/nvim_http/config.lua new file mode 100644 index 0000000..74be6dc --- /dev/null +++ b/lua/nvim_http/config.lua @@ -0,0 +1,24 @@ +local M = {} + +M.defaults = { + http = { + enabled = true, + execute_keymap = "hr", + clear_keymap = "hc", + timeout_ms = 10000, + highlight_group = "Comment", + command_patterns = { + "^curl%s+", + "^http%s+", + "^wget%s+", + "^%u+%s+https?://", + "^https?://", + }, + }, +} + +function M.merge(opts) + return vim.tbl_deep_extend("force", {}, M.defaults, opts or {}) +end + +return M diff --git a/lua/nvim_http/init.lua b/lua/nvim_http/init.lua new file mode 100644 index 0000000..ab890d5 --- /dev/null +++ b/lua/nvim_http/init.lua @@ -0,0 +1,767 @@ +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 + diff --git a/nvim-http-scm-1.rockspec b/nvim-http-scm-1.rockspec new file mode 100644 index 0000000..783a9f7 --- /dev/null +++ b/nvim-http-scm-1.rockspec @@ -0,0 +1,18 @@ +package = "nvim-http" +version = "scm-1" +rockspec_format = "3.0" + +source = { + url = "git+https://github.com/macocianradu/nvim-http", +} + +description = { + summary = "Neovim HTTP runner plugin", + license = "MIT", +} + +dependencies = { + "lua >= 5.1", + "lyaml", +} + diff --git a/plugin/nvim-http.lua b/plugin/nvim-http.lua new file mode 100644 index 0000000..d78d3e8 --- /dev/null +++ b/plugin/nvim-http.lua @@ -0,0 +1,17 @@ +local ok, nvim_http = pcall(require, "nvim_http") +if not ok then + return +end + +vim.api.nvim_create_user_command("NvimHttpRun", function() + nvim_http.run_http_under_cursor() +end, { + desc = "Run HTTP command under cursor and open Telescope response tree", +}) + +vim.api.nvim_create_user_command("NvimHttpClear", function() + nvim_http.clear_http_result() +end, { + desc = "Close and clear HTTP response tree", +}) +