all repos

curl.nvim @ 7c9dc5c60780a467d14f19c73d714a13765c2758

use curl within neovim

curl.nvim/lua/curl.lua (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
updaties, 1 month ago
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
9
local H = {
10
  cache_dir = vim.fs.joinpath(vim.fn.stdpath "data", "curl_cache"),
11
  query_buf = nil,
12
  query_file = nil,
13
  query_buffers = {},
14
  query_autocmd_set = false,
15
  output_buf = nil,
16
  command_created = false,
17
  running_request = nil,
18
}
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
30
31
local function is_valid_buf(buf)
32
  return type(buf) == "number" and buf > 0 and vim.api.nvim_buf_is_valid(buf)
33
end
34
35
local function is_valid_win(win)
36
  return type(win) == "number" and win > 0 and vim.api.nvim_win_is_valid(win)
37
end
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
55
local function trim_lines(lines)
56
  while #lines > 1 and lines[#lines] == "" do
57
    table.remove(lines)
58
  end
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)
65
end
66
67
local function is_query_start(line)
68
  if not line then return false end
69
  return line:match "^%s*curl%s" or line:match "^%s*curl$" or line:match "^%s*https?://"
70
end
71
72
local function is_curl_file(path)
73
  return type(path) == "string" and path ~= "" and path:sub(-5) == ".curl"
74
end
75
76
local function get_or_create_buffer(name)
77
  local existing = vim.fn.bufnr("^" .. name .. "$")
78
  if existing ~= -1 and is_valid_buf(existing) then return existing end
79
80
  local buf = vim.api.nvim_create_buf(false, true)
81
  vim.api.nvim_buf_set_name(buf, name)
82
  configure_scratch_buffer(buf)
83
  return buf
84
end
85
86
local function ensure_dir(path)
87
  if vim.fn.isdirectory(path) == 1 then return true end
88
  return vim.fn.mkdir(path, "p") == 1
89
end
90
91
local function get_workspace_id()
92
  local cwd = vim.fn.getcwd()
93
  local base = vim.fn.fnamemodify(cwd, ":t")
94
  if base == "" then base = "root" end
95
  return base .. "_" .. vim.fn.sha256(cwd):sub(1, 8)
96
end
97
98
local function get_cache_file()
99
  if not ensure_dir(H.cache_dir) then
100
    vim.notify("[curl.nvim] failed to create cache dir: " .. H.cache_dir, vim.log.levels.ERROR)
101
    return nil
102
  end
103
104
  local cwd = vim.fn.getcwd()
105
  local hash = vim.fn.sha256(cwd)
106
  local new_file = vim.fs.joinpath(H.cache_dir, get_workspace_id() .. ".curl")
107
  local old_file = vim.fs.joinpath(H.cache_dir, hash)
108
109
  if vim.uv.fs_stat(old_file) then
110
    if not vim.uv.fs_stat(new_file) then
111
      vim.fn.rename(old_file, new_file)
112
    else
113
      vim.fn.rename(old_file, old_file .. ".archive")
114
    end
115
  end
116
117
  return new_file
118
end
119
120
local function save_query_buffer(buf, file)
121
  if not is_valid_buf(buf) then return end
122
  file = file or vim.api.nvim_buf_get_name(buf)
123
  if not is_curl_file(file) then return end
124
125
  local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
126
  local ok = pcall(vim.fn.writefile, lines, file, "b")
127
  if ok then
128
    pcall(vim.api.nvim_set_option_value, "modified", false, { buf = buf })
129
  else
130
    vim.notify("[curl.nvim] failed to save request file: " .. file, vim.log.levels.ERROR)
131
  end
132
end
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
265
function H.set_query_mapping(buf)
266
  if not is_valid_buf(buf) then return end
267
268
  vim.keymap.set("n", curl.config.map_execute, curl.execute, {
269
    buffer = buf,
270
    noremap = true,
271
    silent = true,
272
    desc = "Execute curl query under cursor",
273
  })
274
end
275
276
function H.attach_curl_buffer(buf)
277
  if not is_valid_buf(buf) then return false end
278
  local name = vim.api.nvim_buf_get_name(buf)
279
  if not is_curl_file(name) then return false end
280
281
  set_buffer_option(buf, "filetype", "curl")
282
  set_buffer_option(buf, "syntax", "sh")
283
  set_buffer_option(buf, "commentstring", "# %s")
284
  pcall(vim.treesitter.language.register, "bash", "curl")
285
  H.set_query_mapping(buf)
286
  H.query_buffers[name] = buf
287
  return true
288
end
289
290
local function create_query_buffer(file)
291
  local buf = vim.fn.bufadd(file)
292
  vim.fn.bufload(buf)
293
  configure_query_buffer(buf)
294
  H.attach_curl_buffer(buf)
295
296
  local group = vim.api.nvim_create_augroup("curl_nvim_cache_" .. buf, { clear = true })
297
  vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufLeave", "VimLeavePre" }, {
298
    group = group,
299
    buffer = buf,
300
    callback = function()
301
      save_query_buffer(buf, file)
302
    end,
303
  })
304
305
  return buf
306
end
307
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
317
end
318
319
function H.setup_filetype()
320
  vim.filetype.add {
321
    extension = { curl = "curl" },
322
  }
323
end
324
325
function H.setup_command()
326
  if H.command_created then return end
327
328
  vim.api.nvim_create_user_command("Curl", function()
329
    curl.open()
330
  end, { desc = "Open curl.nvim query buffer" })
331
  H.command_created = true
332
end
333
334
function H.get_query_buffer()
335
  local file = get_cache_file()
336
  if not file then return nil end
337
338
  local existing = H.query_buffers[file]
339
  if not is_valid_buf(existing) then existing = create_query_buffer(file) end
340
341
  H.query_buffers[file] = existing
342
  H.set_query_context(existing, file)
343
  H.set_query_mapping(existing)
344
  return existing
345
end
346
347
function H.setup_query_file_autocmd()
348
  if H.query_autocmd_set then return end
349
350
  local group = vim.api.nvim_create_augroup("curl_nvim_query_files", { clear = true })
351
  vim.api.nvim_create_autocmd(
352
    { "BufReadPost", "BufNewFile", "BufEnter", "BufWinEnter", "BufFilePost" },
353
    {
354
      group = group,
355
      pattern = "*.curl",
356
      callback = function(args)
357
        H.attach_curl_buffer(args.buf)
358
      end,
359
    }
360
  )
361
362
  for _, buf in ipairs(vim.api.nvim_list_bufs()) do
363
    H.attach_curl_buffer(buf)
364
  end
365
366
  H.query_autocmd_set = true
367
end
368
369
function H.get_output_buffer()
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)
375
  return H.output_buf
376
end
377
378
function H.open_output_window()
379
  local output_buf = H.get_output_buffer()
380
  local query_buf = resolve_query_buffer()
381
  if not query_buf then return nil end
382
383
  local query_win = find_window_in_current_tab(query_buf) or vim.api.nvim_get_current_win()
384
  local output_win = find_window_in_current_tab(output_buf)
385
386
  if is_valid_win(output_win) then return output_win end
387
388
  vim.api.nvim_set_current_win(query_win)
389
  vim.cmd(curl.config.open_cmd)
390
  output_win = vim.api.nvim_get_current_win()
391
  vim.api.nvim_win_set_buf(output_win, output_buf)
392
  vim.api.nvim_set_current_win(query_win)
393
  return output_win
394
end
395
396
function H.find_query_block(lines, cursor_line)
397
  if not lines[cursor_line] or vim.trim(lines[cursor_line]) == "" then return nil end
398
399
  local start_line = nil
400
  for line_no = cursor_line, 1, -1 do
401
    local line = lines[line_no]
402
    if is_query_start(line) then
403
      start_line = line_no
404
      break
405
    end
406
    if vim.trim(line) == "" then break end
407
  end
408
  if not start_line then return nil end
409
410
  local end_line = #lines
411
  for line_no = start_line + 1, #lines do
412
    local line = lines[line_no]
413
    if is_query_start(line) or vim.trim(line) == "" then
414
      end_line = line_no - 1
415
      break
416
    end
417
  end
418
419
  if cursor_line < start_line or cursor_line > end_line then return nil end
420
  return start_line, end_line
421
end
422
423
function H.build_command(query_lines)
424
  local binary = curl.config.curl or "curl"
425
  local body_parts = {}
426
427
  for _, line in ipairs(query_lines) do
428
    local trimmed = vim.trim(line)
429
    if trimmed ~= "" and not trimmed:match "^#" then
430
      table.insert(body_parts, trimmed:gsub("\\%s*$", ""))
431
    end
432
  end
433
434
  local body = vim.trim(table.concat(body_parts, " "))
435
  if body == "" then return nil, "empty query" end
436
437
  local parsed, parse_err = shell_split(body)
438
  if not parsed or #parsed == 0 then return nil, parse_err end
439
  if parsed[1] == "curl" then table.remove(parsed, 1) end
440
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
445
end
446
447
function H.write_formatted_output(output)
448
  if output == "" or vim.fn.executable "jq" ~= 1 then
449
    schedule_output_write(output, false)
450
    return
451
  end
452
453
  local headers, json_candidate = extract_headers_and_json(output)
454
  if not json_candidate or json_candidate == "" then
455
    schedule_output_write(output, false)
456
    return
457
  end
458
459
  vim.system({ "jq", "." }, {
460
    text = true,
461
    stdin = json_candidate,
462
  }, function(result)
463
    local text = output
464
    local is_json = false
465
466
    if result.code == 0 and result.stdout and result.stdout ~= "" then
467
      is_json = true
468
      text = merge_headers_and_json(headers, result.stdout)
469
    end
470
471
    schedule_output_write(text, is_json)
472
  end)
473
end
474
475
function H.write_output(text, is_json)
476
  local output_buf = H.get_output_buffer()
477
  local lines = trim_lines(vim.split(text, "\n", { plain = true }))
478
  if #lines == 0 then lines = { "" } end
479
480
  set_buffer_option(output_buf, "modifiable", true)
481
  vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, lines)
482
  set_buffer_option(output_buf, "modifiable", false)
483
  set_buffer_option(output_buf, "filetype", is_json and "json" or "text")
484
end
485
486
function curl.setup(opts)
487
  local config = vim.tbl_deep_extend("force", curl.config, opts or {})
488
  validate_config(config)
489
  curl.config = config
490
491
  H.setup_filetype()
492
  H.setup_command()
493
  H.setup_query_file_autocmd()
494
end
495
496
function curl.open()
497
  local query_buf = H.get_query_buffer()
498
  if not query_buf then return end
499
500
  local query_win = find_window_in_current_tab(query_buf)
501
  if is_valid_win(query_win) then
502
    vim.api.nvim_set_current_win(query_win)
503
  else
504
    vim.api.nvim_set_current_buf(query_buf)
505
  end
506
end
507
508
function curl.execute()
509
  local query_buf = vim.api.nvim_get_current_buf()
510
  local query_file = vim.api.nvim_buf_get_name(query_buf)
511
  if not is_curl_file(query_file) then
512
    vim.notify("[curl.nvim] execute from a .curl request buffer", vim.log.levels.WARN)
513
    return
514
  end
515
  H.set_query_context(query_buf, query_file)
516
517
  local lines = vim.api.nvim_buf_get_lines(query_buf, 0, -1, false)
518
  local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
519
  local start_line, end_line = H.find_query_block(lines, cursor_line)
520
  if not start_line then
521
    vim.notify("[curl.nvim] cursor is not on a query line", vim.log.levels.INFO)
522
    return
523
  end
524
525
  local query_lines = vim.list_slice(lines, start_line, end_line)
526
  local command, command_err = H.build_command(query_lines)
527
  if not command then
528
    vim.notify("[curl.nvim] " .. (command_err or "invalid query"), vim.log.levels.WARN)
529
    return
530
  end
531
532
  save_query_buffer(query_buf, query_file)
533
  H.open_output_window()
534
  stop_running_request()
535
536
  H.running_request = vim.system(command, { text = true }, function(result)
537
    H.running_request = nil
538
    H.write_formatted_output(result_to_output(result))
539
  end)
540
end
541
542
return curl