2 files changed,
254 insertions(+),
182 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-04-19 22:44:58 +0300
Authored at:
2026-04-19 13:51:45 +0300
Change ID:
pvumzrvzwmmzzqmwkvskqpwwvvullwyl
Parent:
7fbfcc5
jump to
| M | lua/curl.lua |
| M | lua/curl/health.lua |
M
lua/curl.lua
··· 1 +local curl = {} 2 +curl.config = { 3 + curl = nil, -- path to binary 4 + default_flags = { "-i" }, 5 + open_cmd = "vsplit", 6 + map_execute = "<CR>", -- in query buffer 7 +} 8 + 1 9 local H = { 2 10 cache_dir = vim.fs.joinpath(vim.fn.stdpath "data", "curl_cache"), 3 11 query_buf = nil, ··· 9 17 running_request = nil, 10 18 } 11 19 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 +local function validate_config(config) 21 + vim.validate("curl.config", config, "table") 22 + vim.validate("curl.config.curl", config.curl, { "string", "nil" }) 23 + vim.validate("curl.config.default_flags", config.default_flags, "table") 24 + vim.validate("curl.config.open_cmd", config.open_cmd, "string") 25 + vim.validate("curl.config.map_execute", config.map_execute, "string") 26 + for i, flag in ipairs(config.default_flags) do 27 + vim.validate("curl.config.default_flags[" .. i .. "]", flag, "string") 28 + end 29 +end 20 30 21 31 local function is_valid_buf(buf) 22 32 return type(buf) == "number" and buf > 0 and vim.api.nvim_buf_is_valid(buf) ··· 26 36 return type(win) == "number" and win > 0 and vim.api.nvim_win_is_valid(win) 27 37 end 28 38 39 +local function set_buffer_option(buf, name, value) 40 + vim.api.nvim_set_option_value(name, value, { buf = buf }) 41 +end 42 + 43 +local function configure_scratch_buffer(buf) 44 + set_buffer_option(buf, "buftype", "nofile") 45 + set_buffer_option(buf, "bufhidden", "hide") 46 + set_buffer_option(buf, "swapfile", false) 47 +end 48 + 49 +local function configure_query_buffer(buf) 50 + set_buffer_option(buf, "bufhidden", "hide") 51 + set_buffer_option(buf, "swapfile", false) 52 + set_buffer_option(buf, "modifiable", true) 53 +end 54 + 29 55 local function trim_lines(lines) 30 56 while #lines > 1 and lines[#lines] == "" do 31 57 table.remove(lines) 32 58 end 33 59 return lines 60 +end 61 + 62 +local function trim(s) 63 + local from = s:match "^%s*()" 64 + return from > #s and "" or s:match(".*%S", from) 34 65 end 35 66 36 67 local function is_query_start(line) ··· 48 79 49 80 local buf = vim.api.nvim_create_buf(false, true) 50 81 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 }) 82 + configure_scratch_buffer(buf) 54 83 return buf 55 84 end 56 85 ··· 92 121 if not is_valid_buf(buf) then return end 93 122 file = file or vim.api.nvim_buf_get_name(buf) 94 123 if not is_curl_file(file) then return end 124 + 95 125 local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) 96 126 local ok = pcall(vim.fn.writefile, lines, file, "b") 97 127 if ok then ··· 101 131 end 102 132 end 103 133 134 +local function find_window_in_current_tab(bufnr) 135 + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do 136 + if vim.api.nvim_win_get_buf(win) == bufnr then return win end 137 + end 138 + return nil 139 +end 140 + 141 +local function is_json_start_line(line) 142 + return trim(line):match "^[%[%{]" ~= nil 143 +end 144 + 145 +local function schedule_output_write(text, is_json) 146 + vim.schedule(function() 147 + H.write_output(text, is_json) 148 + end) 149 +end 150 + 151 +local function extract_headers_and_json(output) 152 + local normalized = output:gsub("\r\n", "\n"):gsub("\r", "\n") 153 + if is_json_start_line(normalized) then return {}, normalized end 154 + 155 + local lines = vim.split(normalized, "\n", { plain = true }) 156 + local json_index = nil 157 + for i, line in ipairs(lines) do 158 + if is_json_start_line(line) then 159 + json_index = i 160 + break 161 + end 162 + end 163 + 164 + if not json_index then return nil, nil end 165 + 166 + local headers = {} 167 + for i = 1, json_index - 1 do 168 + table.insert(headers, trim(lines[i])) 169 + end 170 + 171 + local json_lines = {} 172 + for i = json_index, #lines do 173 + table.insert(json_lines, lines[i]) 174 + end 175 + 176 + return headers, table.concat(json_lines, "\n") 177 +end 178 + 179 +local function merge_headers_and_json(headers, json) 180 + if not headers or #headers == 0 then return json end 181 + local merged = vim.deepcopy(headers) 182 + vim.list_extend(merged, vim.split(json, "\n", { plain = true })) 183 + return table.concat(merged, "\n") 184 +end 185 + 186 +local function shell_split(input) 187 + local args = {} 188 + local current = {} 189 + local quote = nil 190 + local i = 1 191 + 192 + while i <= #input do 193 + local ch = input:sub(i, i) 194 + 195 + if quote == "'" then 196 + if ch == "'" then 197 + quote = nil 198 + else 199 + table.insert(current, ch) 200 + end 201 + elseif quote == '"' then 202 + if ch == '"' then 203 + quote = nil 204 + elseif ch == "\\" and i < #input then 205 + i = i + 1 206 + table.insert(current, input:sub(i, i)) 207 + else 208 + table.insert(current, ch) 209 + end 210 + else 211 + if ch == "'" or ch == '"' then 212 + quote = ch 213 + elseif ch:match "%s" then 214 + if #current > 0 then 215 + table.insert(args, table.concat(current)) 216 + current = {} 217 + end 218 + elseif ch == "\\" and i < #input then 219 + i = i + 1 220 + table.insert(current, input:sub(i, i)) 221 + else 222 + table.insert(current, ch) 223 + end 224 + end 225 + 226 + i = i + 1 227 + end 228 + 229 + if quote then return nil, "unterminated quote in query" end 230 + if #current > 0 then table.insert(args, table.concat(current)) end 231 + return args 232 +end 233 + 234 +local function resolve_query_buffer() 235 + if is_valid_buf(H.query_buf) then return H.query_buf end 236 + 237 + local current = vim.api.nvim_get_current_buf() 238 + local current_name = vim.api.nvim_buf_get_name(current) 239 + if is_curl_file(current_name) then 240 + H.set_query_context(current, current_name) 241 + return current 242 + end 243 + 244 + return H.get_query_buffer() 245 +end 246 + 247 +local function stop_running_request() 248 + if not H.running_request then return end 249 + pcall(function() 250 + H.running_request:kill(15) 251 + end) 252 + H.running_request = nil 253 +end 254 + 255 +local function result_to_output(result) 256 + local chunks = {} 257 + if result.stdout and result.stdout ~= "" then table.insert(chunks, result.stdout) end 258 + if result.stderr and result.stderr ~= "" then table.insert(chunks, result.stderr) end 259 + 260 + if #chunks == 0 then return "curl exited with code " .. result.code end 261 + 262 + return table.concat(chunks, "\n\n") 263 +end 264 + 104 265 function H.set_query_mapping(buf) 105 266 if not is_valid_buf(buf) then return end 267 + 106 268 vim.keymap.set("n", curl.config.map_execute, curl.execute, { 107 269 buffer = buf, 108 270 noremap = true, ··· 115 277 if not is_valid_buf(buf) then return false end 116 278 local name = vim.api.nvim_buf_get_name(buf) 117 279 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 }) 280 + 281 + set_buffer_option(buf, "filetype", "curl") 282 + set_buffer_option(buf, "syntax", "sh") 283 + set_buffer_option(buf, "commentstring", "# %s") 121 284 pcall(vim.treesitter.language.register, "bash", "curl") 122 285 H.set_query_mapping(buf) 123 286 H.query_buffers[name] = buf ··· 127 290 local function create_query_buffer(file) 128 291 local buf = vim.fn.bufadd(file) 129 292 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 }) 293 + configure_query_buffer(buf) 133 294 H.attach_curl_buffer(buf) 134 295 135 296 local group = vim.api.nvim_create_augroup("curl_nvim_cache_" .. buf, { clear = true }) ··· 144 305 return buf 145 306 end 146 307 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 308 +function H.set_query_context(buf, file) 309 + if not is_valid_buf(buf) then return false end 310 + file = file or vim.api.nvim_buf_get_name(buf) 311 + if not is_curl_file(file) then return false end 312 + 313 + H.query_buf = buf 314 + H.query_file = file 315 + H.query_buffers[file] = buf 316 + return true 152 317 end 153 318 154 319 function H.setup_filetype() ··· 159 324 160 325 function H.setup_command() 161 326 if H.command_created then return end 327 + 162 328 vim.api.nvim_create_user_command("Curl", function() 163 329 curl.open() 164 330 end, { desc = "Open curl.nvim query buffer" }) ··· 169 335 local file = get_cache_file() 170 336 if not file then return nil end 171 337 172 - H.query_file = file 173 338 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) 339 + if not is_valid_buf(existing) then existing = create_query_buffer(file) end 181 340 182 - return H.query_buf 341 + H.query_buffers[file] = existing 342 + H.set_query_context(existing, file) 343 + H.set_query_mapping(existing) 344 + return existing 183 345 end 184 346 185 347 function H.setup_query_file_autocmd() 186 348 if H.query_autocmd_set then return end 349 + 187 350 local group = vim.api.nvim_create_augroup("curl_nvim_query_files", { clear = true }) 188 351 vim.api.nvim_create_autocmd( 189 352 { "BufReadPost", "BufNewFile", "BufEnter", "BufWinEnter", "BufFilePost" }, ··· 195 358 end, 196 359 } 197 360 ) 361 + 198 362 for _, buf in ipairs(vim.api.nvim_list_bufs()) do 199 363 H.attach_curl_buffer(buf) 200 364 end 365 + 201 366 H.query_autocmd_set = true 202 367 end 203 368 204 369 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 370 + if is_valid_buf(H.output_buf) then return H.output_buf end 371 + 372 + H.output_buf = get_or_create_buffer "Curl output" 373 + set_buffer_option(H.output_buf, "filetype", "json") 374 + set_buffer_option(H.output_buf, "modifiable", false) 210 375 return H.output_buf 211 376 end 212 377 213 378 function H.open_output_window() 214 379 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 380 + local query_buf = resolve_query_buffer() 218 381 if not query_buf then return nil end 382 + 219 383 local query_win = find_window_in_current_tab(query_buf) or vim.api.nvim_get_current_win() 220 384 local output_win = find_window_in_current_tab(output_buf) 221 385 ··· 256 420 return start_line, end_line 257 421 end 258 422 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 423 function H.build_command(query_lines) 308 424 local binary = curl.config.curl or "curl" 309 425 local body_parts = {} ··· 311 427 for _, line in ipairs(query_lines) do 312 428 local trimmed = vim.trim(line) 313 429 if trimmed ~= "" and not trimmed:match "^#" then 314 - trimmed = trimmed:gsub("\\%s*$", "") 315 - table.insert(body_parts, trimmed) 430 + table.insert(body_parts, trimmed:gsub("\\%s*$", "")) 316 431 end 317 432 end 318 433 ··· 321 436 322 437 local parsed, parse_err = shell_split(body) 323 438 if not parsed or #parsed == 0 then return nil, parse_err end 324 - 325 439 if parsed[1] == "curl" then table.remove(parsed, 1) end 326 440 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") 441 + local command = { binary, "--silent", "--show-error" } 442 + vim.list_extend(command, curl.config.default_flags) 443 + vim.list_extend(command, parsed) 444 + return command, nil 375 445 end 376 446 377 447 function H.write_formatted_output(output) 378 448 if output == "" or vim.fn.executable "jq" ~= 1 then 379 - vim.schedule(function() 380 - H.write_output(output, false) 381 - end) 449 + schedule_output_write(output, false) 382 450 return 383 451 end 384 452 385 453 local headers, json_candidate = extract_headers_and_json(output) 386 454 if not json_candidate or json_candidate == "" then 387 - vim.schedule(function() 388 - H.write_output(output, false) 389 - end) 455 + schedule_output_write(output, false) 390 456 return 391 457 end 392 458 ··· 396 462 }, function(result) 397 463 local text = output 398 464 local is_json = false 465 + 399 466 if result.code == 0 and result.stdout and result.stdout ~= "" then 400 467 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 468 + text = merge_headers_and_json(headers, result.stdout) 414 469 end 415 - vim.schedule(function() 416 - H.write_output(text, is_json) 417 - end) 470 + 471 + schedule_output_write(text, is_json) 418 472 end) 419 473 end 420 474 ··· 423 477 local lines = trim_lines(vim.split(text, "\n", { plain = true })) 424 478 if #lines == 0 then lines = { "" } end 425 479 426 - vim.api.nvim_set_option_value("modifiable", true, { buf = output_buf }) 480 + set_buffer_option(output_buf, "modifiable", true) 427 481 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 }) 482 + set_buffer_option(output_buf, "modifiable", false) 483 + set_buffer_option(output_buf, "filetype", is_json and "json" or "text") 430 484 end 431 485 432 486 function curl.setup(opts) 433 - curl.config = vim.tbl_deep_extend("force", curl.config, opts or {}) 487 + local config = vim.tbl_deep_extend("force", curl.config, opts or {}) 488 + validate_config(config) 489 + curl.config = config 490 + 434 491 H.setup_filetype() 435 492 H.setup_command() 436 493 H.setup_query_file_autocmd() ··· 439 496 function curl.open() 440 497 local query_buf = H.get_query_buffer() 441 498 if not query_buf then return end 499 + 442 500 local query_win = find_window_in_current_tab(query_buf) 443 501 if is_valid_win(query_win) then 444 502 vim.api.nvim_set_current_win(query_win) ··· 454 512 vim.notify("[curl.nvim] execute from a .curl request buffer", vim.log.levels.WARN) 455 513 return 456 514 end 457 - H.query_buf = query_buf 458 - H.query_file = query_file 459 - H.query_buffers[query_file] = query_buf 515 + H.set_query_context(query_buf, query_file) 460 516 461 517 local lines = vim.api.nvim_buf_get_lines(query_buf, 0, -1, false) 462 518 local cursor_line = vim.api.nvim_win_get_cursor(0)[1] ··· 475 531 476 532 save_query_buffer(query_buf, query_file) 477 533 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 534 + stop_running_request() 485 535 486 536 H.running_request = vim.system(command, { text = true }, function(result) 487 537 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) 538 + H.write_formatted_output(result_to_output(result)) 496 539 end) 497 540 end 498 541
M
lua/curl/health.lua
··· 1 +local function check_binary(bin, msg, optional) 2 + if vim.fn.executable(bin) == 1 then 3 + vim.health.ok(bin .. " found on PATH: `" .. vim.fn.exepath(bin) .. "`") 4 + return 5 + end 6 + 7 + if optional then 8 + vim.health.warn(bin .. " not found on PATH, " .. msg) 9 + else 10 + vim.health.error(bin .. " not found on PATH, " .. msg) 11 + end 12 +end 13 + 14 +return { 15 + check = function() 16 + vim.health.start "Neovim version" 17 + if vim.fn.has "nvim-0.12" == 1 then 18 + vim.health.ok "Neovim version is compatible" 19 + else 20 + vim.health.error "nvim-0.12 or newer is required" 21 + end 22 + 23 + vim.health.start "Required dependencies" 24 + check_binary("curl", "required to run requests", false) 25 + 26 + vim.health.start "Optional dependencies" 27 + check_binary("jq", "used for JSON formatting in output buffers", true) 28 + end, 29 +}