all repos

curl.nvim @ 53cfede

use curl within neovim

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
formatting and ai slop mention, 1 month ago
1
local H = {
2
  cache_dir = vim.fs.joinpath(vim.fn.stdpath "data", "curl_cache"),
3
  query_buffers = {},
4
  query_autocmd_set = false,
5
  output_buf = nil,
6
  command_created = false,
7
  running_request = nil,
8
}
9
10
local curl = {}
11
curl.config = {
12
  curl = nil, -- path to binary
13
  default_flags = { "-i" },
14
  open_cmd = "vsplit",
15
  map_execute = "<CR>",
16
}
17
18
function curl.setup(opts)
19
  opts = opts or {}
20
  vim.validate("opts", opts, "table")
21
  curl.config = vim.tbl_deep_extend("force", curl.config, opts)
22
  vim.filetype.add { extension = { curl = "curl" } }
23
24
  if not H.command_created then
25
    vim.api.nvim_create_user_command("Curl", function()
26
      curl.open()
27
    end, { desc = "Open curl.nvim query buffer" })
28
    H.command_created = true
29
  end
30
31
  if not H.query_autocmd_set then
32
    vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile", "BufEnter", "BufWinEnter", "BufFilePost" }, {
33
      group = vim.api.nvim_create_augroup("curl_nvim_query_files", { clear = true }),
34
      pattern = "*.curl",
35
      callback = function(args)
36
        H.attach_curl_buffer(args.buf)
37
      end,
38
    })
39
    for _, buf in ipairs(vim.api.nvim_list_bufs()) do
40
      H.attach_curl_buffer(buf)
41
    end
42
    H.query_autocmd_set = true
43
  end
44
end
45
46
function curl.open()
47
  local query_buf = H.get_query_buffer()
48
  if not query_buf then return end
49
  local query_win = H.find_window(query_buf)
50
  if query_win and vim.api.nvim_win_is_valid(query_win) then
51
    vim.api.nvim_set_current_win(query_win)
52
  else
53
    vim.api.nvim_set_current_buf(query_buf)
54
  end
55
end
56
57
function curl.execute()
58
  local query_buf = vim.api.nvim_get_current_buf()
59
  local query_file = vim.api.nvim_buf_get_name(query_buf)
60
  if not H.is_curl_file(query_file) then
61
    vim.notify("[curl.nvim] execute from a .curl request buffer", vim.log.levels.WARN)
62
    return
63
  end
64
  H.query_buffers[query_file] = query_buf
65
66
  local lines = vim.api.nvim_buf_get_lines(query_buf, 0, -1, false)
67
  local start_line, end_line = H.find_query_block(lines, vim.api.nvim_win_get_cursor(0)[1])
68
  if not start_line then
69
    vim.notify("[curl.nvim] cursor is not on a query line", vim.log.levels.INFO)
70
    return
71
  end
72
73
  local command, err = H.build_command(vim.list_slice(lines, start_line, end_line))
74
  if not command then
75
    vim.notify("[curl.nvim] " .. (err or "invalid query"), vim.log.levels.WARN)
76
    return
77
  end
78
79
  H.save_query_buffer(query_buf, query_file)
80
  H.open_output_window(query_buf)
81
82
  if H.running_request then
83
    pcall(function()
84
      H.running_request:kill(15)
85
    end)
86
    H.running_request = nil
87
  end
88
89
  H.running_request = vim.system(command, { text = true }, function(result)
90
    H.running_request = nil
91
    local output = result.stdout or ""
92
    if result.stderr and result.stderr ~= "" then
93
      output = output == "" and result.stderr or (output .. "\n\n" .. result.stderr)
94
    end
95
    if output == "" then output = "curl exited with code " .. result.code end
96
    H.write_formatted_output(output)
97
  end)
98
end
99
100
function H.is_valid_buf(buf)
101
  return type(buf) == "number" and buf > 0 and vim.api.nvim_buf_is_valid(buf)
102
end
103
104
function H.is_curl_file(path)
105
  return type(path) == "string" and path ~= "" and path:sub(-5) == ".curl"
106
end
107
108
function H.is_query_start(line)
109
  if not line then return false end
110
  return line:match "^%s*curl%s" or line:match "^%s*curl$" or line:match "^%s*https?://"
111
end
112
113
function H.save_query_buffer(buf, file)
114
  if not H.is_valid_buf(buf) then return end
115
  file = file or vim.api.nvim_buf_get_name(buf)
116
  if not H.is_curl_file(file) then return end
117
118
  local ok = pcall(vim.fn.writefile, vim.api.nvim_buf_get_lines(buf, 0, -1, false), file, "b")
119
  if ok then
120
    pcall(vim.api.nvim_set_option_value, "modified", false, { buf = buf })
121
  else
122
    vim.notify("[curl.nvim] failed to save request file: " .. file, vim.log.levels.ERROR)
123
  end
124
end
125
126
function H.query_cache_file()
127
  if vim.fn.isdirectory(H.cache_dir) ~= 1 and vim.fn.mkdir(H.cache_dir, "p") ~= 1 then
128
    vim.notify("[curl.nvim] failed to create cache dir: " .. H.cache_dir, vim.log.levels.ERROR)
129
    return nil
130
  end
131
132
  local cwd = vim.fn.getcwd()
133
  local base = vim.fn.fnamemodify(cwd, ":t")
134
  if base == "" then base = "root" end
135
  return vim.fs.joinpath(H.cache_dir, base .. "_" .. vim.fn.sha256(cwd):sub(1, 8) .. ".curl")
136
end
137
138
function H.attach_curl_buffer(buf)
139
  if not H.is_valid_buf(buf) then return false end
140
  local name = vim.api.nvim_buf_get_name(buf)
141
  if not H.is_curl_file(name) then return false end
142
143
  vim.api.nvim_set_option_value("filetype", "curl", { buf = buf })
144
  vim.api.nvim_set_option_value("syntax", "sh", { buf = buf })
145
  vim.api.nvim_set_option_value("commentstring", "# %s", { buf = buf })
146
  pcall(vim.treesitter.language.register, "bash", "curl")
147
  vim.keymap.set("n", curl.config.map_execute, curl.execute, {
148
    buffer = buf,
149
    noremap = true,
150
    silent = true,
151
    desc = "Execute curl query under cursor",
152
  })
153
  H.query_buffers[name] = buf
154
  return true
155
end
156
157
function H.get_query_buffer()
158
  local file = H.query_cache_file()
159
  if not file then return nil end
160
161
  local buf = H.query_buffers[file]
162
  if not H.is_valid_buf(buf) then
163
    buf = vim.fn.bufadd(file)
164
    vim.fn.bufload(buf)
165
    vim.api.nvim_set_option_value("bufhidden", "hide", { buf = buf })
166
    vim.api.nvim_set_option_value("swapfile", false, { buf = buf })
167
    vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
168
169
    vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufLeave", "VimLeavePre" }, {
170
      group = vim.api.nvim_create_augroup("curl_nvim_cache_" .. buf, { clear = true }),
171
      buffer = buf,
172
      callback = function()
173
        H.save_query_buffer(buf, file)
174
      end,
175
    })
176
  end
177
178
  H.attach_curl_buffer(buf)
179
  return buf
180
end
181
182
local function get_output_buffer()
183
  if H.is_valid_buf(H.output_buf) then return H.output_buf end
184
185
  local existing = vim.fn.bufnr "^Curl output$"
186
  if existing ~= -1 and H.is_valid_buf(existing) then
187
    H.output_buf = existing
188
  else
189
    H.output_buf = vim.api.nvim_create_buf(false, true)
190
    vim.api.nvim_buf_set_name(H.output_buf, "Curl output")
191
    vim.api.nvim_set_option_value("buftype", "nofile", { buf = H.output_buf })
192
    vim.api.nvim_set_option_value("bufhidden", "hide", { buf = H.output_buf })
193
    vim.api.nvim_set_option_value("swapfile", false, { buf = H.output_buf })
194
  end
195
196
  vim.api.nvim_set_option_value("filetype", "json", { buf = H.output_buf })
197
  vim.api.nvim_set_option_value("modifiable", false, { buf = H.output_buf })
198
  return H.output_buf
199
end
200
201
function H.find_window(buf)
202
  for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
203
    if vim.api.nvim_win_get_buf(win) == buf then return win end
204
  end
205
end
206
207
function H.open_output_window(query_buf)
208
  local output_buf = get_output_buffer()
209
  local output_win = H.find_window(output_buf)
210
  if output_win and vim.api.nvim_win_is_valid(output_win) then return output_win end
211
212
  local query_win = H.find_window(query_buf) or vim.api.nvim_get_current_win()
213
  if not vim.api.nvim_win_is_valid(query_win) then return nil end
214
215
  vim.api.nvim_set_current_win(query_win)
216
  vim.cmd(curl.config.open_cmd)
217
  vim.api.nvim_win_set_buf(0, output_buf)
218
  vim.api.nvim_set_current_win(query_win)
219
  return vim.api.nvim_get_current_win()
220
end
221
222
function H.find_query_block(lines, cursor_line)
223
  if not lines[cursor_line] or vim.trim(lines[cursor_line]) == "" then return nil end
224
225
  local start_line
226
  for i = cursor_line, 1, -1 do
227
    if H.is_query_start(lines[i]) then
228
      start_line = i
229
      break
230
    end
231
    if vim.trim(lines[i]) == "" then break end
232
  end
233
  if not start_line then return nil end
234
235
  local end_line = #lines
236
  for i = start_line + 1, #lines do
237
    if H.is_query_start(lines[i]) or vim.trim(lines[i]) == "" then
238
      end_line = i - 1
239
      break
240
    end
241
  end
242
243
  if cursor_line < start_line or cursor_line > end_line then return nil end
244
  return start_line, end_line
245
end
246
247
function H.shell_split(input)
248
  local args, current, quote = {}, {}, nil
249
  local i = 1
250
  while i <= #input do
251
    local ch = input:sub(i, i)
252
    if quote == "'" then
253
      if ch == "'" then
254
        quote = nil
255
      else
256
        table.insert(current, ch)
257
      end
258
    elseif quote == '"' then
259
      if ch == '"' then
260
        quote = nil
261
      elseif ch == "\\" and i < #input then
262
        i = i + 1
263
        table.insert(current, input:sub(i, i))
264
      else
265
        table.insert(current, ch)
266
      end
267
    else
268
      if ch == "'" or ch == '"' then
269
        quote = ch
270
      elseif ch:match "%s" then
271
        if #current > 0 then
272
          table.insert(args, table.concat(current))
273
          current = {}
274
        end
275
      elseif ch == "\\" and i < #input then
276
        i = i + 1
277
        table.insert(current, input:sub(i, i))
278
      else
279
        table.insert(current, ch)
280
      end
281
    end
282
    i = i + 1
283
  end
284
285
  if quote then return nil, "unterminated quote in query" end
286
  if #current > 0 then table.insert(args, table.concat(current)) end
287
  return args
288
end
289
290
function H.build_command(query_lines)
291
  local body_parts = {}
292
  for _, line in ipairs(query_lines) do
293
    local s = vim.trim(line)
294
    if s ~= "" and not s:match "^#" then table.insert(body_parts, s:gsub("\\%s*$", "")) end
295
  end
296
297
  local body = vim.trim(table.concat(body_parts, " "))
298
  if body == "" then return nil, "empty query" end
299
300
  local parsed, err = H.shell_split(body)
301
  if not parsed or #parsed == 0 then return nil, err end
302
  if parsed[1] == "curl" then table.remove(parsed, 1) end
303
304
  local cmd = { curl.config.curl or "curl", "--silent", "--show-error" }
305
  vim.list_extend(cmd, curl.config.default_flags or {})
306
  vim.list_extend(cmd, parsed)
307
  return cmd
308
end
309
310
function H.write_output(text, is_json)
311
  local buf = get_output_buffer()
312
  local lines = vim.split(text or "", "\n", { plain = true })
313
  while #lines > 1 and lines[#lines] == "" do
314
    table.remove(lines)
315
  end
316
  if #lines == 0 then lines = { "" } end
317
318
  vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
319
  vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
320
  vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
321
  vim.api.nvim_set_option_value("filetype", is_json and "json" or "text", { buf = buf })
322
end
323
324
function H.write_formatted_output(output)
325
  if output == "" or vim.fn.executable "jq" ~= 1 then
326
    vim.schedule(function()
327
      H.write_output(output, false)
328
    end)
329
    return
330
  end
331
332
  local lines = vim.split(output:gsub("\r\n", "\n"):gsub("\r", "\n"), "\n", { plain = true })
333
  local json_index
334
  for i, line in ipairs(lines) do
335
    if vim.trim(line):match "^[%[%{]" then
336
      json_index = i
337
      break
338
    end
339
  end
340
  if not json_index then
341
    vim.schedule(function()
342
      H.write_output(output, false)
343
    end)
344
    return
345
  end
346
347
  local headers = {}
348
  for i = 1, json_index - 1 do
349
    table.insert(headers, vim.trim(lines[i]))
350
  end
351
  local json_body = table.concat(vim.list_slice(lines, json_index, #lines), "\n")
352
353
  vim.system({ "jq", "." }, { text = true, stdin = json_body }, function(result)
354
    local text, is_json = output, false
355
    if result.code == 0 and result.stdout and result.stdout ~= "" then
356
      is_json = true
357
      text = #headers > 0 and (table.concat(headers, "\n") .. "\n" .. result.stdout) or result.stdout
358
    end
359
    vim.schedule(function()
360
      H.write_output(text, is_json)
361
    end)
362
  end)
363
end
364
365
return curl