3 files changed,
506 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-04-19 22:44:58 +0300
Authored at:
2026-04-19 13:36:38 +0300
Change ID:
tultozytukwvtvtzyuzwxkxksspuukvk
jump to
| A | lua/curl.lua |
| A | lua/curl/health.lua |
| A | stylua.toml |
A
lua/curl.lua
··· 1 +local H = { 2 + cache_dir = vim.fs.joinpath(vim.fn.stdpath "data", "curl_cache"), 3 + query_buf = nil, 4 + query_file = nil, 5 + query_buffers = {}, 6 + query_autocmd_set = false, 7 + output_buf = nil, 8 + command_created = false, 9 + running_request = nil, 10 +} 11 + 12 +local curl = {} 13 + 14 +curl.config = { 15 + curl = nil, -- path to binary 16 + default_flags = { "-i" }, 17 + open_cmd = "vsplit", 18 + map_execute = "<CR>", -- in query buffer 19 +} 20 + 21 +local function is_valid_buf(buf) 22 + return type(buf) == "number" and buf > 0 and vim.api.nvim_buf_is_valid(buf) 23 +end 24 + 25 +local function is_valid_win(win) 26 + return type(win) == "number" and win > 0 and vim.api.nvim_win_is_valid(win) 27 +end 28 + 29 +local function trim_lines(lines) 30 + while #lines > 1 and lines[#lines] == "" do 31 + table.remove(lines) 32 + end 33 + return lines 34 +end 35 + 36 +local function is_query_start(line) 37 + if not line then return false end 38 + return line:match "^%s*curl%s" or line:match "^%s*curl$" or line:match "^%s*https?://" 39 +end 40 + 41 +local function is_curl_file(path) 42 + return type(path) == "string" and path ~= "" and path:sub(-5) == ".curl" 43 +end 44 + 45 +local function get_or_create_buffer(name) 46 + local existing = vim.fn.bufnr("^" .. name .. "$") 47 + if existing ~= -1 and is_valid_buf(existing) then return existing end 48 + 49 + local buf = vim.api.nvim_create_buf(false, true) 50 + vim.api.nvim_buf_set_name(buf, name) 51 + vim.api.nvim_set_option_value("buftype", "nofile", { buf = buf }) 52 + vim.api.nvim_set_option_value("bufhidden", "hide", { buf = buf }) 53 + vim.api.nvim_set_option_value("swapfile", false, { buf = buf }) 54 + return buf 55 +end 56 + 57 +local function ensure_dir(path) 58 + if vim.fn.isdirectory(path) == 1 then return true end 59 + return vim.fn.mkdir(path, "p") == 1 60 +end 61 + 62 +local function get_workspace_id() 63 + local cwd = vim.fn.getcwd() 64 + local base = vim.fn.fnamemodify(cwd, ":t") 65 + if base == "" then base = "root" end 66 + return base .. "_" .. vim.fn.sha256(cwd):sub(1, 8) 67 +end 68 + 69 +local function get_cache_file() 70 + if not ensure_dir(H.cache_dir) then 71 + vim.notify("[curl.nvim] failed to create cache dir: " .. H.cache_dir, vim.log.levels.ERROR) 72 + return nil 73 + end 74 + 75 + local cwd = vim.fn.getcwd() 76 + local hash = vim.fn.sha256(cwd) 77 + local new_file = vim.fs.joinpath(H.cache_dir, get_workspace_id() .. ".curl") 78 + local old_file = vim.fs.joinpath(H.cache_dir, hash) 79 + 80 + if vim.uv.fs_stat(old_file) then 81 + if not vim.uv.fs_stat(new_file) then 82 + vim.fn.rename(old_file, new_file) 83 + else 84 + vim.fn.rename(old_file, old_file .. ".archive") 85 + end 86 + end 87 + 88 + return new_file 89 +end 90 + 91 +local function save_query_buffer(buf, file) 92 + if not is_valid_buf(buf) then return end 93 + file = file or vim.api.nvim_buf_get_name(buf) 94 + if not is_curl_file(file) then return end 95 + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) 96 + local ok = pcall(vim.fn.writefile, lines, file, "b") 97 + if ok then 98 + pcall(vim.api.nvim_set_option_value, "modified", false, { buf = buf }) 99 + else 100 + vim.notify("[curl.nvim] failed to save request file: " .. file, vim.log.levels.ERROR) 101 + end 102 +end 103 + 104 +function H.set_query_mapping(buf) 105 + if not is_valid_buf(buf) then return end 106 + vim.keymap.set("n", curl.config.map_execute, curl.execute, { 107 + buffer = buf, 108 + noremap = true, 109 + silent = true, 110 + desc = "Execute curl query under cursor", 111 + }) 112 +end 113 + 114 +function H.attach_curl_buffer(buf) 115 + if not is_valid_buf(buf) then return false end 116 + local name = vim.api.nvim_buf_get_name(buf) 117 + if not is_curl_file(name) then return false end 118 + vim.api.nvim_set_option_value("filetype", "curl", { buf = buf }) 119 + vim.api.nvim_set_option_value("syntax", "sh", { buf = buf }) 120 + vim.api.nvim_set_option_value("commentstring", "# %s", { buf = buf }) 121 + pcall(vim.treesitter.language.register, "bash", "curl") 122 + H.set_query_mapping(buf) 123 + H.query_buffers[name] = buf 124 + return true 125 +end 126 + 127 +local function create_query_buffer(file) 128 + local buf = vim.fn.bufadd(file) 129 + vim.fn.bufload(buf) 130 + vim.api.nvim_set_option_value("bufhidden", "hide", { buf = buf }) 131 + vim.api.nvim_set_option_value("swapfile", false, { buf = buf }) 132 + vim.api.nvim_set_option_value("modifiable", true, { buf = buf }) 133 + H.attach_curl_buffer(buf) 134 + 135 + local group = vim.api.nvim_create_augroup("curl_nvim_cache_" .. buf, { clear = true }) 136 + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufLeave", "VimLeavePre" }, { 137 + group = group, 138 + buffer = buf, 139 + callback = function() 140 + save_query_buffer(buf, file) 141 + end, 142 + }) 143 + 144 + return buf 145 +end 146 + 147 +local function find_window_in_current_tab(bufnr) 148 + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do 149 + if vim.api.nvim_win_get_buf(win) == bufnr then return win end 150 + end 151 + return nil 152 +end 153 + 154 +function H.setup_filetype() 155 + vim.filetype.add { 156 + extension = { curl = "curl" }, 157 + } 158 +end 159 + 160 +function H.setup_command() 161 + if H.command_created then return end 162 + vim.api.nvim_create_user_command("Curl", function() 163 + curl.open() 164 + end, { desc = "Open curl.nvim query buffer" }) 165 + H.command_created = true 166 +end 167 + 168 +function H.get_query_buffer() 169 + local file = get_cache_file() 170 + if not file then return nil end 171 + 172 + H.query_file = file 173 + local existing = H.query_buffers[file] 174 + if not is_valid_buf(existing) then 175 + existing = create_query_buffer(file) 176 + H.query_buffers[file] = existing 177 + end 178 + H.query_buf = existing 179 + 180 + H.set_query_mapping(H.query_buf) 181 + 182 + return H.query_buf 183 +end 184 + 185 +function H.setup_query_file_autocmd() 186 + if H.query_autocmd_set then return end 187 + local group = vim.api.nvim_create_augroup("curl_nvim_query_files", { clear = true }) 188 + vim.api.nvim_create_autocmd( 189 + { "BufReadPost", "BufNewFile", "BufEnter", "BufWinEnter", "BufFilePost" }, 190 + { 191 + group = group, 192 + pattern = "*.curl", 193 + callback = function(args) 194 + H.attach_curl_buffer(args.buf) 195 + end, 196 + } 197 + ) 198 + for _, buf in ipairs(vim.api.nvim_list_bufs()) do 199 + H.attach_curl_buffer(buf) 200 + end 201 + H.query_autocmd_set = true 202 +end 203 + 204 +function H.get_output_buffer() 205 + if not is_valid_buf(H.output_buf) then 206 + H.output_buf = get_or_create_buffer "Curl output" 207 + vim.api.nvim_set_option_value("filetype", "json", { buf = H.output_buf }) 208 + vim.api.nvim_set_option_value("modifiable", false, { buf = H.output_buf }) 209 + end 210 + return H.output_buf 211 +end 212 + 213 +function H.open_output_window() 214 + local output_buf = H.get_output_buffer() 215 + local query_buf = H.query_buf 216 + if not is_valid_buf(query_buf) then query_buf = vim.api.nvim_get_current_buf() end 217 + if not is_valid_buf(query_buf) then query_buf = H.get_query_buffer() end 218 + if not query_buf then return nil end 219 + local query_win = find_window_in_current_tab(query_buf) or vim.api.nvim_get_current_win() 220 + local output_win = find_window_in_current_tab(output_buf) 221 + 222 + if is_valid_win(output_win) then return output_win end 223 + 224 + vim.api.nvim_set_current_win(query_win) 225 + vim.cmd(curl.config.open_cmd) 226 + output_win = vim.api.nvim_get_current_win() 227 + vim.api.nvim_win_set_buf(output_win, output_buf) 228 + vim.api.nvim_set_current_win(query_win) 229 + return output_win 230 +end 231 + 232 +function H.find_query_block(lines, cursor_line) 233 + if not lines[cursor_line] or vim.trim(lines[cursor_line]) == "" then return nil end 234 + 235 + local start_line = nil 236 + for line_no = cursor_line, 1, -1 do 237 + local line = lines[line_no] 238 + if is_query_start(line) then 239 + start_line = line_no 240 + break 241 + end 242 + if vim.trim(line) == "" then break end 243 + end 244 + if not start_line then return nil end 245 + 246 + local end_line = #lines 247 + for line_no = start_line + 1, #lines do 248 + local line = lines[line_no] 249 + if is_query_start(line) or vim.trim(line) == "" then 250 + end_line = line_no - 1 251 + break 252 + end 253 + end 254 + 255 + if cursor_line < start_line or cursor_line > end_line then return nil end 256 + return start_line, end_line 257 +end 258 + 259 +local function shell_split(input) 260 + local args = {} 261 + local current = {} 262 + local quote = nil 263 + local i = 1 264 + 265 + while i <= #input do 266 + local ch = input:sub(i, i) 267 + 268 + if quote == "'" then 269 + if ch == "'" then 270 + quote = nil 271 + else 272 + table.insert(current, ch) 273 + end 274 + elseif quote == '"' then 275 + if ch == '"' then 276 + quote = nil 277 + elseif ch == "\\" and i < #input then 278 + i = i + 1 279 + table.insert(current, input:sub(i, i)) 280 + else 281 + table.insert(current, ch) 282 + end 283 + else 284 + if ch == "'" or ch == '"' then 285 + quote = ch 286 + elseif ch:match "%s" then 287 + if #current > 0 then 288 + table.insert(args, table.concat(current)) 289 + current = {} 290 + end 291 + elseif ch == "\\" and i < #input then 292 + i = i + 1 293 + table.insert(current, input:sub(i, i)) 294 + else 295 + table.insert(current, ch) 296 + end 297 + end 298 + 299 + i = i + 1 300 + end 301 + 302 + if quote then return nil, "unterminated quote in query" end 303 + if #current > 0 then table.insert(args, table.concat(current)) end 304 + return args 305 +end 306 + 307 +function H.build_command(query_lines) 308 + local binary = curl.config.curl or "curl" 309 + local body_parts = {} 310 + 311 + for _, line in ipairs(query_lines) do 312 + local trimmed = vim.trim(line) 313 + if trimmed ~= "" and not trimmed:match "^#" then 314 + trimmed = trimmed:gsub("\\%s*$", "") 315 + table.insert(body_parts, trimmed) 316 + end 317 + end 318 + 319 + local body = vim.trim(table.concat(body_parts, " ")) 320 + if body == "" then return nil, "empty query" end 321 + 322 + local parsed, parse_err = shell_split(body) 323 + if not parsed or #parsed == 0 then return nil, parse_err end 324 + 325 + if parsed[1] == "curl" then table.remove(parsed, 1) end 326 + 327 + local parts = { binary, "--silent", "--show-error" } 328 + for _, flag in ipairs(curl.config.default_flags) do 329 + table.insert(parts, flag) 330 + end 331 + 332 + for _, arg in ipairs(parsed) do 333 + table.insert(parts, arg) 334 + end 335 + 336 + return parts, nil 337 +end 338 + 339 +local function trim(s) 340 + local from = s:match "^%s*()" 341 + return from > #s and "" or s:match(".*%S", from) 342 +end 343 + 344 +local function is_json_start_line(line) 345 + return trim(line):match "^[%[%{]" ~= nil 346 +end 347 + 348 +local function extract_headers_and_json(output) 349 + local normalized = output:gsub("\r\n", "\n"):gsub("\r", "\n") 350 + local lines = vim.split(normalized, "\n", { plain = true }) 351 + 352 + if is_json_start_line(normalized) then return {}, normalized end 353 + 354 + local json_index = nil 355 + for i, line in ipairs(lines) do 356 + if is_json_start_line(line) then 357 + json_index = i 358 + break 359 + end 360 + end 361 + 362 + if not json_index then return nil, nil end 363 + 364 + local headers = {} 365 + for i = 1, json_index - 1 do 366 + table.insert(headers, trim(lines[i])) 367 + end 368 + 369 + local json_lines = {} 370 + for i = json_index, #lines do 371 + table.insert(json_lines, lines[i]) 372 + end 373 + 374 + return headers, table.concat(json_lines, "\n") 375 +end 376 + 377 +function H.write_formatted_output(output) 378 + if output == "" or vim.fn.executable "jq" ~= 1 then 379 + vim.schedule(function() 380 + H.write_output(output, false) 381 + end) 382 + return 383 + end 384 + 385 + local headers, json_candidate = extract_headers_and_json(output) 386 + if not json_candidate or json_candidate == "" then 387 + vim.schedule(function() 388 + H.write_output(output, false) 389 + end) 390 + return 391 + end 392 + 393 + vim.system({ "jq", "." }, { 394 + text = true, 395 + stdin = json_candidate, 396 + }, function(result) 397 + local text = output 398 + local is_json = false 399 + if result.code == 0 and result.stdout and result.stdout ~= "" then 400 + is_json = true 401 + if headers and #headers > 0 then 402 + local merged = {} 403 + for _, line in ipairs(headers) do 404 + table.insert(merged, line) 405 + end 406 + local json_lines = vim.split(result.stdout, "\n", { plain = true }) 407 + for _, line in ipairs(json_lines) do 408 + table.insert(merged, line) 409 + end 410 + text = table.concat(merged, "\n") 411 + else 412 + text = result.stdout 413 + end 414 + end 415 + vim.schedule(function() 416 + H.write_output(text, is_json) 417 + end) 418 + end) 419 +end 420 + 421 +function H.write_output(text, is_json) 422 + local output_buf = H.get_output_buffer() 423 + local lines = trim_lines(vim.split(text, "\n", { plain = true })) 424 + if #lines == 0 then lines = { "" } end 425 + 426 + vim.api.nvim_set_option_value("modifiable", true, { buf = output_buf }) 427 + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, lines) 428 + vim.api.nvim_set_option_value("modifiable", false, { buf = output_buf }) 429 + vim.api.nvim_set_option_value("filetype", is_json and "json" or "text", { buf = output_buf }) 430 +end 431 + 432 +function curl.setup(opts) 433 + curl.config = vim.tbl_deep_extend("force", curl.config, opts or {}) 434 + H.setup_filetype() 435 + H.setup_command() 436 + H.setup_query_file_autocmd() 437 +end 438 + 439 +function curl.open() 440 + local query_buf = H.get_query_buffer() 441 + if not query_buf then return end 442 + local query_win = find_window_in_current_tab(query_buf) 443 + if is_valid_win(query_win) then 444 + vim.api.nvim_set_current_win(query_win) 445 + else 446 + vim.api.nvim_set_current_buf(query_buf) 447 + end 448 +end 449 + 450 +function curl.execute() 451 + local query_buf = vim.api.nvim_get_current_buf() 452 + local query_file = vim.api.nvim_buf_get_name(query_buf) 453 + if not is_curl_file(query_file) then 454 + vim.notify("[curl.nvim] execute from a .curl request buffer", vim.log.levels.WARN) 455 + return 456 + end 457 + H.query_buf = query_buf 458 + H.query_file = query_file 459 + H.query_buffers[query_file] = query_buf 460 + 461 + local lines = vim.api.nvim_buf_get_lines(query_buf, 0, -1, false) 462 + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] 463 + local start_line, end_line = H.find_query_block(lines, cursor_line) 464 + if not start_line then 465 + vim.notify("[curl.nvim] cursor is not on a query line", vim.log.levels.INFO) 466 + return 467 + end 468 + 469 + local query_lines = vim.list_slice(lines, start_line, end_line) 470 + local command, command_err = H.build_command(query_lines) 471 + if not command then 472 + vim.notify("[curl.nvim] " .. (command_err or "invalid query"), vim.log.levels.WARN) 473 + return 474 + end 475 + 476 + save_query_buffer(query_buf, query_file) 477 + H.open_output_window() 478 + 479 + if H.running_request then 480 + pcall(function() 481 + H.running_request:kill(15) 482 + end) 483 + H.running_request = nil 484 + end 485 + 486 + H.running_request = vim.system(command, { text = true }, function(result) 487 + H.running_request = nil 488 + 489 + local combined = result.stdout or "" 490 + if result.stderr and result.stderr ~= "" then 491 + combined = combined == "" and result.stderr or (combined .. "\n\n" .. result.stderr) 492 + end 493 + if combined == "" then combined = "curl exited with code " .. result.code end 494 + 495 + H.write_formatted_output(combined) 496 + end) 497 +end 498 + 499 +return curl