all repos

curl.nvim @ 7fbfcc5

use curl within neovim

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
impl, 1 month ago
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