diff --git a/docs/docs/usage/public-methods.md b/docs/docs/usage/public-methods.md index 2aab258..e7bfbbd 100644 --- a/docs/docs/usage/public-methods.md +++ b/docs/docs/usage/public-methods.md @@ -40,6 +40,12 @@ It's default contents can be configured via the `require('kulala').copy()` copies the current request (as cURL command) to the clipboard. +### from_curl + +`require('kulala').from_curl()` parse the cURL command from the clipboard and +write the HTTP spec into current buffer. It is useful for importing requests +from other tools like browsers. + ### close `require('kulala').close()` closes the kulala window and also the current buffer. diff --git a/lua/kulala/init.lua b/lua/kulala/init.lua index 8dd646b..6593235 100644 --- a/lua/kulala/init.lua +++ b/lua/kulala/init.lua @@ -37,6 +37,10 @@ M.show_stats = function() UI:show_stats() end +M.from_curl = function() + UI:from_curl() +end + M.version = function() local neovim_version = vim.fn.execute("version") or "Unknown" Logger.info("Kulala version: " .. GLOBALS.VERSION .. "\n\n" .. "Neovim version: " .. neovim_version) diff --git a/lua/kulala/lib/shlex/init.lua b/lua/kulala/lib/shlex/init.lua new file mode 100644 index 0000000..c50c01f --- /dev/null +++ b/lua/kulala/lib/shlex/init.lua @@ -0,0 +1,336 @@ +-- All credits due to the original author of this code +-- https://github.com/BodneyC/shlex-lua/ +-- +-- It has been modified to work with Neovim and removed some continue statements +-- that could be also breaks, idk about performance but it works and +-- doesn't break stylua. +-- +-- The original code has no license, so I'm assuming it's public domain +-- The author states that: +-- +-- It's a couple files you can drop into your project +-- (or even your Neovim config (which is why I wrote it)) +-- if you need to parse a shell command. + +local M = {} + +local function some(o) + return o and #o > 0 +end + +local function none(o) + return not some(o) +end + +M.shlex = { + whitespace = " \t\r\n", + whitespace_split = false, + quotes = [['"]], + escape = [[\]], + escapedquotes = '"', + state = " ", + pushback = {}, + lineno = 1, + debug = 0, + token = "", + commenters = "#", + wordchars = "abcdfeghijklmnopqrstuvwxyz" .. "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", +} +M.shlex.__index = M.shlex + +local sr = require("kulala.lib.shlex.stringreader") + +function M.shlex:create(str, posix, punctuation_chars) + local o = {} + setmetatable(o, M.shlex) + + o.sr = sr(str or "") + + if not posix then + o.eof = "" + end + + o.posix = posix == true + if o.posix then + o.wordchars = o.wordchars + .. "ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ" + .. "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ" + end + + if punctuation_chars then + punctuation_chars = "();<>|&" + else + punctuation_chars = "" + end + + o.punctuation_chars = punctuation_chars + + if punctuation_chars then + o._pushback_chars = {} + o.wordchars = o.wordchars .. "~-./*?=" + for i = 1, #o.punctuation_chars do + local c = o.punctuation_chars:sub(i, i) + o.wordchars:gsub(c, "", 1, true) + end + end + + return o +end + +function M.shlex:push_token(tok) + table.insert(self.pushback, tok) +end + +function M.shlex:read_token() + local quoted = false + local escapedstate = " " + local nextchar + + while true do + if some(self.punctuation_chars) and some(self._pushback_chars) then + nextchar = table.remove(self._pushback_chars) + else + nextchar = self.sr:read(1) + end + + if nextchar == "\n" then + self.lineno = self.lineno + 1 + end + + if self.debug >= 3 then + print("shlex: in state '" .. (self.state or "nil") .. "' I see character: '" .. (nextchar or "nil") .. "'") + end + + if none(self.state) then + self.token = "" + break + elseif self.state == " " then + if none(nextchar) then + self.state = nil + break + elseif self.whitespace:find(nextchar, 1, true) then + if self.debug >= 2 then + print("shlex: I see whitespace in whitespace state") + end + if some(self.token) or (self.posix and quoted) then + break + else + break + end + elseif self.commenters:find(nextchar, 1, true) then + self.sr:readline() + self.lineno = self.lineno + 1 + elseif self.posix and self.escape:find(nextchar, 1, true) then + escapedstate = "a" + self.state = nextchar + elseif self.wordchars:find(nextchar, 1, true) then + self.token = nextchar + self.state = "a" + elseif self.punctuation_chars:find(nextchar, 1, true) then + self.token = nextchar + self.state = "c" + elseif self.quotes:find(nextchar, 1, true) then + if not self.posix then + self.token = nextchar + end + self.state = nextchar + elseif self.whitespace_split then + self.token = nextchar + self.state = "a" + else + self.token = nextchar + if some(self.token) or (self.posix and quoted) then + break + else + break + end + end + elseif self.quotes:find(self.state, 1, true) then + quoted = true + if none(nextchar) then + if self.debug >= 2 then + print("shlex: I see EOF in quotes state") + end + error("no closing quotation") + end + if nextchar == self.state then + if not self.posix then + self.token = self.token .. nextchar + self.state = " " + break + else + self.state = "a" + end + elseif self.posix and self.escape:find(nextchar, 1, true) and self.escapedquotes:find(self.state, 1, true) then + escapedstate = self.state + self.state = nextchar + else + self.token = self.token .. nextchar + end + elseif self.escape:find(self.state, 1, true) then + if none(nextchar) then + if self.debug >= 2 then + print("shlex: I see EOF in escape state") + end + error("no escaped character") + end + if self.quotes:find(escapedstate, 1, true) and nextchar ~= self.state and nextchar ~= escapedstate then + self.token = self.token .. self.state + end + self.token = self.token .. nextchar + self.state = escapedstate + elseif self.state == "a" or self.state == "c" then + if none(nextchar) then + self.state = nil + break + elseif self.whitespace:find(nextchar, 1, true) then + if self.debug >= 2 then + print("shlex: I see whitespace in word state") + end + self.state = " " + if some(self.token) or (self.posix and quoted) then + break + else + break + end + elseif self.commenters:find(nextchar, 1, true) then + self.sr:readline() + self.lineno = self.lineno + 1 + if self.posix then + self.state = " " + if some(self.token) or (self.posix and quoted) then + break + else + break + end + end + elseif self.state == "c" then + if self.punctuation_chars:find(nextchar, 1, true) then + self.token = self.token .. nextchar + else + if not self.whitespace:find(nextchar, 1, true) then + table.insert(self._pushback_chars, nextchar) + end + self.state = " " + break + end + elseif self.posix and self.quotes:find(nextchar, 1, true) then + self.state = nextchar + elseif self.posix and self.escape:find(nextchar, 1, true) then + escapedstate = "a" + self.state = nextchar + elseif + self.wordchars:find(nextchar, 1, true) + or self.quotes:find(nextchar, 1, true) + or (self.whitespace_split and not self.punctuation_chars:find(nextchar, 1, true)) + then + self.token = self.token .. nextchar + else + if some(self.punctuation_chars) then + table.insert(self._pushback_chars, nextchar) + else + table.insert(self.pushback, nextchar) + end + self.state = " " + if some(self.token) or (self.posix and quoted) then + break + else + break + end + end + end + end + + local result = self.token + self.token = "" + if self.posix and not quoted and result == "" then + result = nil + end + if result and result:find("^%s*$") then + result = self:read_token() + end + if self.debug > 1 then + if result then + print("shlex: raw token=" .. result) + else + print("shlex: raw token=EOF") + end + end + return result +end + +function M.shlex:next() + if some(self.pushback) then + return table.remove(self.pushback) + end + local raw = self:read_token() + return raw +end + +function M.shlex:list() + local parts = {} + while true do + local next = self:next() + if next == self.eof or next == nil then + break + end + table.insert(parts, next) + end + return parts +end + +setmetatable(M.shlex, { + __call = M.shlex.create, +}) + +function M.split(str, comments, posix) + if not str then + str = "" + end + if type(posix) == "nil" then + posix = true + end + local lex = M.shlex(str) + lex.posix = posix + if comments == false then + lex.commenters = "" + end + return lex:list() +end + +function M.join(parts) + local ret = "" + for idx, part in ipairs(parts) do + ret = ret .. M.quote(part) + if idx ~= #parts then + ret = ret .. " " + end + end + return ret +end + +M._unsafe = "^@%+=:,./-" + +function M.quote(s) + if none(s) then + return [['']] + end + local found = false + if s:find("%w") then + found = true + else + for i = 1, #s do + local c = s:sub(i, i) + if M._unsafe:find(c, 1, true) then + found = true + break + end + end + end + if not found then + return s + end + return "'" .. s:gsub("'", "'\"'\"'") .. "'" +end + +return M diff --git a/lua/kulala/lib/shlex/stringreader.lua b/lua/kulala/lib/shlex/stringreader.lua new file mode 100644 index 0000000..1281128 --- /dev/null +++ b/lua/kulala/lib/shlex/stringreader.lua @@ -0,0 +1,84 @@ +--- https://raw.githubusercontent.com/BodneyC/shlex-lua/main/stringreader.lua +--- Adapted from https://gist.github.com/MikuAuahDark/e6428ac49248dd436f67c6c64fcec604 +local M = {} +M.__index = M + +function M.create(str) + local o = setmetatable({}, M) + + o.buffer = str or "" + o.pos = 0 + o.__index = M + + return o +end + +function M.read(sr, num) + if num == "*a" then + if sr.pos == #sr.buffer then + return nil + end + + local out = sr.buffer:sub(sr.pos + 1) + + sr.pos = #sr.buffer + return out + elseif num <= 0 then + return "" + end + + local out = sr.buffer:sub(sr.pos + 1, sr.pos + num) + if #out == 0 then + return nil + end + + sr.pos = math.min(sr.pos + num, #sr.buffer) + return out +end + +function M.seek(sr, whence, offset) + whence = whence or "cur" + + if whence == "set" then + sr.pos = offset or 0 + elseif whence == "cur" then + sr.pos = sr.pos + (offset or 0) + elseif whence == "end" then + sr.pos = #sr.buffer + (offset or 0) + else + error("bad argument #1 to 'seek' (invalid option '" .. tostring(whence) .. "')", 2) + end + + sr.pos = math.min(math.max(sr.pos, 0), #sr.buffer) + return sr.pos +end + +function M.readuntil(sr, phrase, exclude) + local rest = sr.buffer:sub(sr.pos + 1) + if not phrase then + sr.pos = #sr.buffer + return rest + end + local idx = rest:find(phrase, 1, true) + if not idx then + return nil + end + if exclude then + idx = idx - 1 + end + local ret = sr.buffer:sub(sr.pos + 1, sr.pos + idx) + sr.pos = sr.pos + idx + return ret +end + +function M.readline(sr) + return sr:readuntil("\n") or sr:readuntil() +end + +setmetatable(M, { + __call = function(_, str) + return M.create(str) + end, +}) + +return M diff --git a/lua/kulala/parser/curl.lua b/lua/kulala/parser/curl.lua new file mode 100644 index 0000000..5a29503 --- /dev/null +++ b/lua/kulala/parser/curl.lua @@ -0,0 +1,109 @@ +local Shlex = require("kulala.lib.shlex") +local Stringutils = require("kulala.utils.string") + +local M = {} + +---Parse a curl command into a Request object +---@param curl string The curl command line to parse +---@return Request|nil -- Table with a parsed data or nil if parsing fails +---@return string|nil -- Original curl command (sanitized one-liner) or nil if parsing fails +function M.parse(curl) + if curl == nil or string.len(curl) == 0 then + return nil, nil + end + + -- Combine multi-line curl commands into a single line. + -- Good for everyone, but especially for + -- Googlers who copy curl commands from their beloved ❤️ Google Chrome DevTools. + -- + -- This is a simple heuristic that assumes that a backslash followed by a newline + -- is a line continuation. This is not always true, but it's good enough for most cases. + -- It should alsow work with Windows-style line endings. + -- If you have a better idea, please submit a PR. + curl = string.gsub(curl, "\\\r?\n", "") + + -- remove extra spaces, + -- they confuse the Shlex parser and might be present in the output of the above heuristic + curl = string.gsub(curl, "%s+", " ") + + local parts = Shlex.split(curl) + -- if string doesn't start with curl, return nil + -- it could also be curl-7.68.0 or something like that + if string.find(parts[1], "^curl.*") == nil then + return nil, nil + end + local res = { + method = "", + headers = {}, + data = nil, + url = "", + http_version = "", + } + + local State = { + START = 0, + Method = 1, + UserAgent = 2, + Header = 3, + Body = 4, + } + local state = State.START + + for _, arg in ipairs(parts) do + local skip = false + if state == State.START then + if arg:match("^[a-z0-9]+://") and res.url == "" then + res.url = arg + elseif arg == "-X" or arg == "--request" then + state = State.Method + elseif arg == "-A" or arg == "--user-agent" then + state = State.UserAgent + elseif arg == "-H" or arg == "--header" then + state = State.Header + elseif arg == "-d" or arg == "--data" or arg == "--data-raw" then + state = State.Body + if res.method == "" then + res.method = "POST" + end + if res.headers["content-type"] == nil then + res.headers["content-type"] = "application/x-www-form-urlencoded" + end + elseif arg == "--json" then + state = State.Body + res.headers["content-type"] = "application/json" + res.headers["accept"] = "application/json" + elseif arg == "--http1.1" then + res.http_version = "HTTP/1.1" + elseif arg == "--http2" then + res.http_version = "HTTP/2" + elseif arg == "--http3" then + res.http_version = "HTTP/3" + end + skip = true + end + + if not skip then + if state == State.Method then + res.method = arg + elseif state == State.UserAgent then + res.headers["user-agent"] = arg + elseif state == State.Header then + local header, value = Stringutils.cut(arg, ":") + res.headers[Stringutils.remove_extra_space(header)] = Stringutils.remove_extra_space(value) + elseif state == State.Body then + res.body = arg + end + end + + if not skip then + state = State.START + end + end + + if res.method == "" then + res.method = "GET" + end + return res, curl +end + +return M diff --git a/lua/kulala/ui/init.lua b/lua/kulala/ui/init.lua index 155c46b..97b4f91 100644 --- a/lua/kulala/ui/init.lua +++ b/lua/kulala/ui/init.lua @@ -4,6 +4,7 @@ local GLOBALS = require("kulala.globals") local CONFIG = require("kulala.config") local INLAY = require("kulala.inlay") local PARSER = require("kulala.parser") +local CURL_PARSER = require("kulala.parser.curl") local CMD = require("kulala.cmd") local FS = require("kulala.utils.fs") local DB = require("kulala.db") @@ -12,7 +13,6 @@ local FORMATTER = require("kulala.formatter") local TS = require("kulala.parser.treesitter") local Logger = require("kulala.logger") local AsciiUtils = require("kulala.utils.ascii") - local Inspect = require("kulala.parser.inspect") local M = {} @@ -122,6 +122,31 @@ local function pretty_ms(ms) return string.format("%.2fms", ms) end +---Prints the parsed Request table into current buffer - uses nvim_put +local function print_http_spec(spec, curl) + local lines = {} + local idx = 1 + + table.insert(lines, "# " .. curl) + + if spec.http_version ~= "" then + table.insert(lines, spec.method .. " " .. spec.url .. " " .. spec.http_version) + else + table.insert(lines, spec.method .. " " .. spec.url) + end + + for header, value in pairs(spec.headers) do + table.insert(lines, header .. ": " .. value) + end + + if spec.body ~= "" then + table.insert(lines, "") + -- FIXME: broken for multi-line body + table.insert(lines, spec.body) + end + vim.api.nvim_put(lines, "l", false, false) +end + M.copy = function() local result = PARSER.parse() local cmd_table = {} @@ -148,6 +173,17 @@ M.copy = function() vim.notify("Copied to clipboard", vim.log.levels.INFO) end +M.from_curl = function() + local clipboard = vim.fn.getreg("+") + local spec, curl = CURL_PARSER.parse(clipboard) + if spec == nil then + Logger.error("Failed to parse curl command") + return + end + -- put the curl command in the buffer as comment + print_http_spec(spec, curl) +end + M.open = function() INLAY.clear() vim.schedule(function() diff --git a/lua/kulala/utils/string.lua b/lua/kulala/utils/string.lua index 07ead57..ccb332a 100644 --- a/lua/kulala/utils/string.lua +++ b/lua/kulala/utils/string.lua @@ -39,4 +39,12 @@ M.url_decode = function(str) return str end +M.cut = function(str, delimiter) + local pos = string.find(str, delimiter) + if pos == nil then + return str, "" + end + return string.sub(str, 1, pos - 1), string.sub(str, pos + 1) +end + return M