Skip to content

Commit 0cc65a9

Browse files
feat(curl): basic curl command parser
1 parent c471a04 commit 0cc65a9

File tree

4 files changed

+200
-23
lines changed

4 files changed

+200
-23
lines changed

lua/rest-nvim/parser/curl.lua

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---@mod rest-nvim.parser.curl rest.nvim curl parsing module
2+
---
3+
---@brief [[
4+
---
5+
--- rest.nvim curl command parsing module
6+
--- rest.nvim uses `tree-sitter-bash` as a core parser to parse raw curl commands
7+
---
8+
---@brief ]]
9+
10+
local curl_parser = {}
11+
12+
local utils = require("rest-nvim.utils")
13+
local logger = require("rest-nvim.logger")
14+
15+
---@param node TSNode Tree-sitter request node
16+
---@param source Source
17+
function curl_parser.parse_command(node, source)
18+
assert(node:type() == "command")
19+
assert(utils.ts_field_text(node, "name", source) == "curl")
20+
local arg_nodes = node:field("argument")
21+
if #arg_nodes < 1 then
22+
logger.error("can't parse curl command with 0 arguments")
23+
return
24+
end
25+
local args = {}
26+
for _, arg_node in ipairs(arg_nodes) do
27+
local arg_type = arg_node:type()
28+
if arg_type == "word" then
29+
table.insert(args, vim.treesitter.get_node_text(arg_node, source))
30+
elseif arg_type == "raw_string" then
31+
-- FIXME: expand escaped sequences like `\n`
32+
table.insert(args, vim.treesitter.get_node_text(arg_node, source):sub(2, -2))
33+
else
34+
logger.error(("can't parse argument type: '%s'"):format(arg_type))
35+
return
36+
end
37+
end
38+
return args
39+
end
40+
41+
-- -X, --request
42+
-- The request method to use.
43+
-- -H, --header
44+
-- The request header to include in the request.
45+
-- -u, --user | --basic | --digest
46+
-- The user's credentials to be provided with the request, and the authorization method to use.
47+
-- -d, --data, --data-ascii | --data-binary | --data-raw | --data-urlencode
48+
-- The data to be sent in a POST request.
49+
-- -F, --form
50+
-- The multipart/form-data message to be sent in a POST request.
51+
-- --url
52+
-- The URL to fetch (mostly used when specifying URLs in a config file).
53+
-- -i, --include
54+
-- Defines whether the HTTP response headers are included in the output.
55+
-- -v, --verbose
56+
-- Enables the verbose operating mode.
57+
-- -L, --location
58+
-- Enables resending the request in case the requested page has moved to a different location.
59+
60+
---@param args string[]
61+
function curl_parser.parse_arguments(args)
62+
local iter = vim.iter(args)
63+
---@type rest.Request
64+
local req = {
65+
-- TODO: add this to rest.Request type
66+
meta = {
67+
redirect = false,
68+
},
69+
url = "",
70+
method = "GET",
71+
headers = {},
72+
cookies = {},
73+
handlers = {},
74+
}
75+
local function any(value, list)
76+
return vim.list_contains(list, value)
77+
end
78+
while true do
79+
local arg = iter:next()
80+
if not arg then
81+
break
82+
end
83+
if any(arg, { "-X", "--request" }) then
84+
req.method = iter:next()
85+
elseif any(arg, { "-H", "--header" }) then
86+
local pair = iter:next()
87+
local key, value = pair:match("(%S+):%s*(.*)")
88+
if not key then
89+
logger.error("can't parse header:" .. pair)
90+
else
91+
key = key:lower()
92+
req.headers[key] = req.headers[key] or {}
93+
if value then
94+
table.insert(req.headers[key], value)
95+
end
96+
end
97+
-- TODO: handle more arguments
98+
-- elseif any(arg, { "-u", "--user" }) then
99+
-- elseif arg == "--basic" then
100+
-- elseif arg == "--digest" then
101+
elseif any(arg, { "-d", "--data", "--data-ascii", "--data-raw" }) then
102+
-- handle external body with `@` syntax
103+
local body = iter:next()
104+
if arg ~= "--data-raw" and body:sub(1, 1) == "@" then
105+
req.body = {
106+
__TYPE = "external",
107+
data = {
108+
name = "",
109+
path = body:sub(2),
110+
},
111+
}
112+
else
113+
req.body = {
114+
__TYPE = "raw",
115+
data = body
116+
}
117+
end
118+
-- elseif arg == "--data-binary" then
119+
-- elseif any(arg, { "-F", "--form" }) then
120+
elseif arg == "--url" then
121+
req.url = iter:next()
122+
elseif any(arg, { "-L", "--location" }) then
123+
req.meta.redirect = true
124+
elseif arg:match("^-%a+$") then
125+
local flags_iter = vim.gsplit(arg:sub(2), "")
126+
for flag in flags_iter do
127+
if flag == "L" then
128+
req.meta.redirect = true
129+
end
130+
end
131+
elseif req.url == "" and not vim.startswith(arg, "-") then
132+
req.url = arg
133+
else
134+
logger.warn("unknown argument: " .. arg)
135+
end
136+
end
137+
return req
138+
end
139+
140+
return curl_parser

lua/rest-nvim/parser/init.lua

+15-23
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,6 @@ local NAMED_REQUEST_QUERY = vim.treesitter.query.parse(
3232
]]
3333
)
3434

35-
---@param node TSNode
36-
---@param field string
37-
---@param source Source
38-
---@return string|nil
39-
local function get_node_field_text(node, field, source)
40-
local n = node:field(field)[1]
41-
return n and vim.treesitter.get_node_text(n, source) or nil
42-
end
43-
4435
---@param src string
4536
---@param context rest.Context
4637
---@param encoder? fun(s:string):string
@@ -67,8 +58,8 @@ local function parse_headers(req_node, source, context)
6758
end)
6859
local header_nodes = req_node:field("header")
6960
for _, node in ipairs(header_nodes) do
70-
local key = assert(get_node_field_text(node, "name", source))
71-
local value = get_node_field_text(node, "value", source)
61+
local key = assert(utils.ts_field_text(node, "name", source))
62+
local value = utils.ts_field_text(node, "value", source)
7263
key = expand_variables(key, context):lower()
7364
if value then
7465
value = expand_variables(value, context)
@@ -110,6 +101,7 @@ local function parse_urlencoded_form(str)
110101
logger.error(("Error while parsing query '%s' from urlencoded form '%s'"):format(query_pairs, str))
111102
return nil
112103
end
104+
-- TODO: encode value here
113105
return vim.trim(key) .. "=" .. vim.trim(value)
114106
end)
115107
:join("&")
@@ -126,7 +118,7 @@ function parser.parse_body(content_type, body_node, source, context)
126118
---@cast body rest.Request.Body
127119
if node_type == "external_body" then
128120
body.__TYPE = "external"
129-
local path = assert(get_node_field_text(body_node, "path", source))
121+
local path = assert(utils.ts_field_text(body_node, "path", source))
130122
if type(source) ~= "number" then
131123
logger.error("can't parse external body on non-existing http file")
132124
return
@@ -137,7 +129,7 @@ function parser.parse_body(content_type, body_node, source, context)
137129
basepath = basepath:gsub("^" .. vim.pesc(vim.uv.cwd() .. "/"), "")
138130
path = vim.fs.normalize(vim.fs.joinpath(basepath, path))
139131
body.data = {
140-
name = get_node_field_text(body_node, "name", source),
132+
name = utils.ts_field_text(body_node, "name", source),
141133
path = path,
142134
}
143135
elseif node_type == "json_body" or content_type == "application/json" then
@@ -252,8 +244,8 @@ end
252244
---@param ctx rest.Context
253245
function parser.parse_variable_declaration(vd_node, source, ctx)
254246
vim.validate({ node = utils.ts_node_spec(vd_node, "variable_declaration") })
255-
local name = assert(get_node_field_text(vd_node, "name", source))
256-
local value = vim.trim(assert(get_node_field_text(vd_node, "value", source)))
247+
local name = assert(utils.ts_field_text(vd_node, "name", source))
248+
local value = vim.trim(assert(utils.ts_field_text(vd_node, "value", source)))
257249
value = expand_variables(value, ctx)
258250
ctx:set_global(name, value)
259251
end
@@ -265,8 +257,8 @@ end
265257
local function parse_script(node, source)
266258
local lang = "javascript"
267259
local prev_node = utils.ts_upper_node(node)
268-
if prev_node and prev_node:type() == "comment" and get_node_field_text(prev_node, "name", source) == "lang" then
269-
local value = get_node_field_text(prev_node, "value", source)
260+
if prev_node and prev_node:type() == "comment" and utils.ts_field_text(prev_node, "name", source) == "lang" then
261+
local value = utils.ts_field_text(prev_node, "value", source)
270262
if value then
271263
lang = value
272264
end
@@ -369,7 +361,7 @@ function parser.parse(node, source, ctx)
369361
local start_row = node:range()
370362
parser.eval_context(source, ctx, start_row)
371363
end
372-
local method = get_node_field_text(req_node, "method", source)
364+
local method = utils.ts_field_text(req_node, "method", source)
373365
if not method then
374366
logger.info("no method provided, falling back to 'GET'")
375367
method = "GET"
@@ -384,7 +376,7 @@ function parser.parse(node, source, ctx)
384376
for child, _ in node:iter_children() do
385377
local child_type = child:type()
386378
if child_type == "request" then
387-
url = expand_variables(assert(get_node_field_text(req_node, "url", source)), ctx, utils.escape)
379+
url = expand_variables(assert(utils.ts_field_text(req_node, "url", source)), ctx, utils.escape)
388380
url = url:gsub("\n%s+", "")
389381
elseif child_type == "pre_request_script" then
390382
parser.parse_pre_request_script(child, source, ctx)
@@ -394,9 +386,9 @@ function parser.parse(node, source, ctx)
394386
table.insert(handlers, handler)
395387
end
396388
elseif child_type == "request_separator" then
397-
name = get_node_field_text(child, "value", source)
398-
elseif child_type == "comment" and get_node_field_text(child, "name", source) == "name" then
399-
name = get_node_field_text(child, "value", source) or name
389+
name = utils.ts_field_text(child, "value", source)
390+
elseif child_type == "comment" and utils.ts_field_text(child, "name", source) == "name" then
391+
name = utils.ts_field_text(child, "value", source) or name
400392
elseif child_type == "variable_declaration" then
401393
parser.parse_variable_declaration(child, source, ctx)
402394
end
@@ -450,7 +442,7 @@ function parser.parse(node, source, ctx)
450442
name = name,
451443
method = method,
452444
url = url,
453-
http_version = get_node_field_text(req_node, "version", source),
445+
http_version = utils.ts_field_text(req_node, "version", source),
454446
headers = headers,
455447
cookies = {},
456448
body = body,

lua/rest-nvim/utils.lua

+9
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,15 @@ function utils.ts_upper_node(node)
238238
return min_node
239239
end
240240

241+
---@param node TSNode
242+
---@param field string
243+
---@param source Source
244+
---@return string|nil
245+
function utils.ts_field_text(node, field, source)
246+
local n = node:field(field)[1]
247+
return n and vim.treesitter.get_node_text(n, source) or nil
248+
end
249+
241250
---@param node TSNode
242251
---@param expected_type string
243252
---@return table

spec/parser/curl_parser_spec.lua

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---@module 'luassert'
2+
3+
require("spec.minimal_init")
4+
5+
local parser = require("rest-nvim.parser.curl")
6+
local utils = require("rest-nvim.utils")
7+
8+
describe("curl cli parser", function()
9+
it("parse curl command", function()
10+
local source = [[
11+
curl -sSL -X POST https://example.com \
12+
-H 'Content-Type: application/json' \
13+
-d '{ "foo": 123 }'
14+
]]
15+
local _, tree = utils.ts_parse_source(source, "bash")
16+
local curl_node = assert(tree:root():child(0))
17+
local args = parser.parse_command(curl_node, source)
18+
assert(args)
19+
assert.same({
20+
method = "POST",
21+
url = "https://example.com",
22+
headers = {
23+
["content-type"] = { "application/json" },
24+
},
25+
body = {
26+
__TYPE = "raw",
27+
data = '{ "foo": 123 }',
28+
},
29+
meta = {
30+
redirect = true,
31+
},
32+
cookies = {},
33+
handlers = {},
34+
}, parser.parse_arguments(args))
35+
end)
36+
end)

0 commit comments

Comments
 (0)