Skip to content

Commit 27adca1

Browse files
feat(curl): basic curl command parser
1 parent 1b4aba1 commit 27adca1

File tree

4 files changed

+200
-23
lines changed

4 files changed

+200
-23
lines changed

lua/rest-nvim/parser/curl.lua

Lines changed: 140 additions & 0 deletions
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

Lines changed: 15 additions & 23 deletions
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
---@return string
@@ -63,8 +54,8 @@ local function parse_headers(req_node, source, context)
6354
end)
6455
local header_nodes = req_node:field("header")
6556
for _, node in ipairs(header_nodes) do
66-
local key = assert(get_node_field_text(node, "name", source))
67-
local value = get_node_field_text(node, "value", source)
57+
local key = assert(utils.ts_field_text(node, "name", source))
58+
local value = utils.ts_field_text(node, "value", source)
6859
key = expand_variables(key, context):lower()
6960
if value then
7061
value = expand_variables(value, context)
@@ -106,6 +97,7 @@ local function parse_urlencoded_form(str)
10697
logger.error(("Error while parsing query '%s' from urlencoded form '%s'"):format(query_pairs, str))
10798
return nil
10899
end
100+
-- TODO: encode value here
109101
return vim.trim(key) .. "=" .. vim.trim(value)
110102
end)
111103
:join("&")
@@ -122,7 +114,7 @@ function parser.parse_body(content_type, body_node, source, context)
122114
---@cast body rest.Request.Body
123115
if node_type == "external_body" then
124116
body.__TYPE = "external"
125-
local path = assert(get_node_field_text(body_node, "path", source))
117+
local path = assert(utils.ts_field_text(body_node, "path", source))
126118
if type(source) ~= "number" then
127119
logger.error("can't parse external body on non-existing http file")
128120
return
@@ -133,7 +125,7 @@ function parser.parse_body(content_type, body_node, source, context)
133125
basepath = basepath:gsub("^" .. vim.pesc(vim.uv.cwd() .. "/"), "")
134126
path = vim.fs.normalize(vim.fs.joinpath(basepath, path))
135127
body.data = {
136-
name = get_node_field_text(body_node, "name", source),
128+
name = utils.ts_field_text(body_node, "name", source),
137129
path = path,
138130
}
139131
elseif node_type == "json_body" or content_type == "application/json" then
@@ -248,8 +240,8 @@ end
248240
---@param ctx rest.Context
249241
function parser.parse_variable_declaration(vd_node, source, ctx)
250242
vim.validate({ node = utils.ts_node_spec(vd_node, "variable_declaration") })
251-
local name = assert(get_node_field_text(vd_node, "name", source))
252-
local value = vim.trim(assert(get_node_field_text(vd_node, "value", source)))
243+
local name = assert(utils.ts_field_text(vd_node, "name", source))
244+
local value = vim.trim(assert(utils.ts_field_text(vd_node, "value", source)))
253245
value = expand_variables(value, ctx)
254246
ctx:set_global(name, value)
255247
end
@@ -261,8 +253,8 @@ end
261253
local function parse_script(node, source)
262254
local lang = "javascript"
263255
local prev_node = utils.ts_upper_node(node)
264-
if prev_node and prev_node:type() == "comment" and get_node_field_text(prev_node, "name", source) == "lang" then
265-
local value = get_node_field_text(prev_node, "value", source)
256+
if prev_node and prev_node:type() == "comment" and utils.ts_field_text(prev_node, "name", source) == "lang" then
257+
local value = utils.ts_field_text(prev_node, "value", source)
266258
if value then
267259
lang = value
268260
end
@@ -365,7 +357,7 @@ function parser.parse(node, source, ctx)
365357
local start_row = node:range()
366358
parser.eval_context(source, ctx, start_row)
367359
end
368-
local method = get_node_field_text(req_node, "method", source)
360+
local method = utils.ts_field_text(req_node, "method", source)
369361
if not method then
370362
logger.info("no method provided, falling back to 'GET'")
371363
method = "GET"
@@ -379,7 +371,7 @@ function parser.parse(node, source, ctx)
379371
for child, _ in node:iter_children() do
380372
local child_type = child:type()
381373
if child_type == "request" then
382-
url = expand_variables(assert(get_node_field_text(req_node, "url", source)), ctx)
374+
url = expand_variables(assert(utils.ts_field_text(req_node, "url", source)), ctx)
383375
url = url:gsub("\n%s+", "")
384376
elseif child_type == "pre_request_script" then
385377
parser.parse_pre_request_script(child, source, ctx)
@@ -390,9 +382,9 @@ function parser.parse(node, source, ctx)
390382
table.insert(handlers, handler)
391383
end
392384
elseif child_type == "request_separator" then
393-
name = get_node_field_text(child, "value", source)
394-
elseif child_type == "comment" and get_node_field_text(child, "name", source) == "name" then
395-
name = get_node_field_text(child, "value", source) or name
385+
name = utils.ts_field_text(child, "value", source)
386+
elseif child_type == "comment" and utils.ts_field_text(child, "name", source) == "name" then
387+
name = utils.ts_field_text(child, "value", source) or name
396388
elseif child_type == "variable_declaration" then
397389
parser.parse_variable_declaration(child, source, ctx)
398390
end
@@ -455,7 +447,7 @@ function parser.parse(node, source, ctx)
455447
name = name,
456448
method = method,
457449
url = url,
458-
http_version = get_node_field_text(req_node, "version", source),
450+
http_version = utils.ts_field_text(req_node, "version", source),
459451
headers = headers,
460452
cookies = {},
461453
body = body,

lua/rest-nvim/utils.lua

Lines changed: 9 additions & 0 deletions
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

Lines changed: 36 additions & 0 deletions
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)