From e9e5ffd1d7954da690f29577dc6424e7284a3a56 Mon Sep 17 00:00:00 2001 From: fprotimaru Date: Mon, 4 May 2026 11:20:46 +0500 Subject: [PATCH] feat: parse pasted curl commands in URL bar When a user pastes a `curl ...` command into the URL bar, parse it and populate the active tab's method, URL, headers, and body. JSON bodies are pretty-printed before being placed in the editor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/curl_parser.rs | 516 ++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 2 + src/views/main_view.rs | 62 ++++- src/views/request_view.rs | 63 +++++ 4 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/utils/curl_parser.rs diff --git a/src/utils/curl_parser.rs b/src/utils/curl_parser.rs new file mode 100644 index 0000000..60859a1 --- /dev/null +++ b/src/utils/curl_parser.rs @@ -0,0 +1,516 @@ +use std::collections::HashMap; + +use crate::entities::{Header, HttpMethod, RequestBody}; + +/// Result of parsing a curl command. +#[derive(Debug, Clone)] +pub struct ParsedCurl { + pub method: HttpMethod, + pub url: String, + pub headers: Vec
, + pub body: RequestBody, +} + +/// Quick check whether a string looks like a curl command. +pub fn looks_like_curl(text: &str) -> bool { + let trimmed = text.trim_start(); + trimmed.starts_with("curl ") || trimmed.starts_with("curl\t") || trimmed.starts_with("curl\n") +} + +/// Parse a `curl` command string into a [`ParsedCurl`]. +/// +/// Supports the most common flags: +/// `-X/--request`, `-H/--header`, `-d/--data/--data-raw/--data-binary/--data-urlencode`, +/// `-F/--form`, `-u/--user`, `-A/--user-agent`, `-e/--referer`, `-b/--cookie`, +/// `--url`, `--location`, `--get`, `-G`, `--compressed`, `--insecure`, `-k`. +pub fn parse_curl(input: &str) -> Result { + let tokens = tokenize(input)?; + if tokens.is_empty() { + return Err("Empty curl command".into()); + } + + let mut iter = tokens.into_iter().peekable(); + + // Skip leading "curl" + let first = iter.next().ok_or("Missing curl")?; + if !first.eq_ignore_ascii_case("curl") { + return Err("Command does not start with curl".into()); + } + + let mut url: Option = None; + let mut method: Option = None; + let mut headers: Vec
= Vec::new(); + let mut data_parts: Vec = Vec::new(); + let mut form_parts: Vec<(String, String)> = Vec::new(); + let mut basic_auth: Option = None; + let mut force_get = false; + + while let Some(arg) = iter.next() { + match arg.as_str() { + "-X" | "--request" => { + let v = iter.next().ok_or("Missing value for -X")?; + method = Some(parse_method(&v)); + } + "-H" | "--header" => { + let v = iter.next().ok_or("Missing value for -H")?; + if let Some(h) = parse_header(&v) { + headers.push(h); + } + } + "-d" | "--data" | "--data-raw" | "--data-binary" | "--data-ascii" => { + let v = iter.next().ok_or("Missing value for -d")?; + data_parts.push(v); + } + "--data-urlencode" => { + let v = iter.next().ok_or("Missing value for --data-urlencode")?; + data_parts.push(v); + } + "-F" | "--form" | "--form-string" => { + let v = iter.next().ok_or("Missing value for -F")?; + if let Some((k, val)) = split_once(&v, '=') { + form_parts.push((k.to_string(), val.to_string())); + } + } + "-u" | "--user" => { + let v = iter.next().ok_or("Missing value for -u")?; + basic_auth = Some(v); + } + "-A" | "--user-agent" => { + let v = iter.next().ok_or("Missing value for -A")?; + headers.push(Header::new("User-Agent", v)); + } + "-e" | "--referer" => { + let v = iter.next().ok_or("Missing value for -e")?; + headers.push(Header::new("Referer", v)); + } + "-b" | "--cookie" => { + let v = iter.next().ok_or("Missing value for -b")?; + headers.push(Header::new("Cookie", v)); + } + "--url" => { + let v = iter.next().ok_or("Missing value for --url")?; + url = Some(v); + } + "-G" | "--get" => { + force_get = true; + } + // Flags we silently ignore (no value). + "--location" | "-L" | "--compressed" | "--insecure" | "-k" | "--silent" | "-s" + | "--verbose" | "-v" | "--fail" | "-f" | "--http1.1" | "--http2" | "--http2-prior-knowledge" + | "--no-buffer" | "-N" | "--include" | "-i" | "--head" | "-I" | "--globoff" | "-g" => {} + // Flags we ignore but consume their value. + "-o" | "--output" | "--max-time" | "--connect-timeout" | "--retry" + | "--retry-delay" | "--retry-max-time" | "-w" | "--write-out" | "--cacert" + | "--cert" | "--key" | "-T" | "--upload-file" | "--resolve" | "--proxy" | "-x" + | "--proxy-user" | "--limit-rate" | "--range" | "-r" | "--ciphers" => { + let _ = iter.next(); + } + _ => { + if arg.starts_with("--") { + // Unknown long flag: consume optional value if next isn't a flag. + if matches!(iter.peek(), Some(next) if !next.starts_with('-')) { + // Heuristic: leave alone — many long flags are boolean. + } + } else if arg.starts_with('-') && arg.len() > 1 { + // Unknown short flag, ignore. + } else { + // Positional: treat as URL (first one wins). + if url.is_none() { + url = Some(arg); + } + } + } + } + } + + let url = url.ok_or("Could not find URL in curl command")?; + + if let Some(creds) = basic_auth { + let encoded = base64_encode(creds.as_bytes()); + headers.push(Header::new("Authorization", format!("Basic {}", encoded))); + } + + let body = build_body(&data_parts, &form_parts, &headers); + + let method = if force_get { + HttpMethod::Get + } else { + method.unwrap_or_else(|| { + if !matches!(body, RequestBody::None) { + HttpMethod::Post + } else { + HttpMethod::Get + } + }) + }; + + Ok(ParsedCurl { + method, + url, + headers, + body, + }) +} + +fn parse_method(value: &str) -> HttpMethod { + match value.to_ascii_uppercase().as_str() { + "GET" => HttpMethod::Get, + "POST" => HttpMethod::Post, + "PUT" => HttpMethod::Put, + "DELETE" => HttpMethod::Delete, + "PATCH" => HttpMethod::Patch, + "HEAD" => HttpMethod::Head, + "OPTIONS" => HttpMethod::Options, + _ => HttpMethod::Get, + } +} + +fn parse_header(raw: &str) -> Option
{ + let (key, value) = split_once(raw, ':')?; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() { + None + } else { + Some(Header::new(key, value)) + } +} + +fn split_once(s: &str, sep: char) -> Option<(&str, &str)> { + let idx = s.find(sep)?; + Some((&s[..idx], &s[idx + sep.len_utf8()..])) +} + +fn header_value<'a>(headers: &'a [Header], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|h| h.key.eq_ignore_ascii_case(name)) + .map(|h| h.value.as_str()) +} + +fn build_body( + data_parts: &[String], + form_parts: &[(String, String)], + headers: &[Header], +) -> RequestBody { + if !form_parts.is_empty() { + let fields = form_parts + .iter() + .map(|(k, v)| crate::entities::MultipartField::text(k, v)) + .collect::>(); + return RequestBody::MultipartFormData(fields); + } + + if data_parts.is_empty() { + return RequestBody::None; + } + + let combined = data_parts.join("&"); + let content_type = header_value(headers, "Content-Type").unwrap_or(""); + + if content_type.contains("application/json") || looks_like_json(&combined) { + return RequestBody::Json(combined); + } + + if content_type.contains("application/x-www-form-urlencoded") + || (combined.contains('=') && !combined.contains('\n')) + { + let mut map = HashMap::new(); + for pair in combined.split('&') { + if let Some((k, v)) = split_once(pair, '=') { + map.insert(k.to_string(), v.to_string()); + } else if !pair.is_empty() { + map.insert(pair.to_string(), String::new()); + } + } + if !map.is_empty() { + return RequestBody::FormData(map); + } + } + + RequestBody::Text(combined) +} + +fn looks_like_json(text: &str) -> bool { + let trimmed = text.trim(); + (trimmed.starts_with('{') && trimmed.ends_with('}')) + || (trimmed.starts_with('[') && trimmed.ends_with(']')) +} + +/// Tokenize a shell-style command line, honoring single quotes, double quotes, +/// backslash escapes, and line continuations (`\` at end of line). +fn tokenize(input: &str) -> Result, String> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_token = false; + let mut chars = input.chars().peekable(); + + enum Quote { + None, + Single, + Double, + } + let mut quote = Quote::None; + + while let Some(c) = chars.next() { + match quote { + Quote::None => match c { + '\\' => { + if let Some(&next) = chars.peek() { + // Line continuation: consume newline (and following CR). + if next == '\n' { + chars.next(); + continue; + } + if next == '\r' { + chars.next(); + if let Some(&'\n') = chars.peek() { + chars.next(); + } + continue; + } + chars.next(); + current.push(next); + in_token = true; + } + } + '\'' => { + quote = Quote::Single; + in_token = true; + } + '"' => { + quote = Quote::Double; + in_token = true; + } + c if c.is_whitespace() => { + if in_token { + tokens.push(std::mem::take(&mut current)); + in_token = false; + } + } + _ => { + current.push(c); + in_token = true; + } + }, + Quote::Single => match c { + '\'' => quote = Quote::None, + _ => current.push(c), + }, + Quote::Double => match c { + '"' => quote = Quote::None, + '\\' => { + if let Some(&next) = chars.peek() { + match next { + '"' | '\\' | '`' | '$' | '\n' => { + chars.next(); + if next != '\n' { + current.push(next); + } + } + _ => current.push('\\'), + } + } + } + _ => current.push(c), + }, + } + } + + if !matches!(quote, Quote::None) { + return Err("Unterminated quote in curl command".into()); + } + if in_token { + tokens.push(current); + } + + Ok(tokens) +} + +/// Minimal RFC 4648 base64 encoder (no external dependency). +fn base64_encode(input: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(((input.len() + 2) / 3) * 4); + let mut i = 0; + while i + 3 <= input.len() { + let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8) | input[i + 2] as u32; + out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char); + out.push(ALPHABET[(n & 0x3f) as usize] as char); + i += 3; + } + let rem = input.len() - i; + if rem == 1 { + let n = (input[i] as u32) << 16; + out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); + out.push('='); + out.push('='); + } else if rem == 2 { + let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8); + out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char); + out.push('='); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_curl_prefix() { + assert!(looks_like_curl("curl https://example.com")); + assert!(looks_like_curl(" curl https://example.com")); + assert!(!looks_like_curl("https://example.com")); + assert!(!looks_like_curl("curlhttps://example.com")); + } + + #[test] + fn parses_simple_get() { + let parsed = parse_curl("curl https://api.example.com/users").unwrap(); + assert_eq!(parsed.method, HttpMethod::Get); + assert_eq!(parsed.url, "https://api.example.com/users"); + assert!(parsed.headers.is_empty()); + assert!(matches!(parsed.body, RequestBody::None)); + } + + #[test] + fn parses_explicit_method() { + let parsed = parse_curl("curl -X DELETE https://api.example.com/users/1").unwrap(); + assert_eq!(parsed.method, HttpMethod::Delete); + assert_eq!(parsed.url, "https://api.example.com/users/1"); + } + + #[test] + fn parses_headers_and_json_body() { + let cmd = r#"curl -X POST 'https://api.example.com/users' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d '{"name":"alice"}'"#; + let parsed = parse_curl(cmd).unwrap(); + assert_eq!(parsed.method, HttpMethod::Post); + assert_eq!(parsed.url, "https://api.example.com/users"); + assert_eq!(parsed.headers.len(), 2); + match &parsed.body { + RequestBody::Json(s) => assert_eq!(s, r#"{"name":"alice"}"#), + other => panic!("expected JSON body, got {:?}", other), + } + } + + #[test] + fn defaults_to_post_when_data_present_without_method() { + let parsed = parse_curl("curl https://api.example.com/x -d 'hello=world'").unwrap(); + assert_eq!(parsed.method, HttpMethod::Post); + } + + #[test] + fn parses_form_urlencoded() { + let parsed = parse_curl( + "curl -X POST https://api.example.com -d 'a=1' -d 'b=2' \ + -H 'Content-Type: application/x-www-form-urlencoded'", + ) + .unwrap(); + match &parsed.body { + RequestBody::FormData(map) => { + assert_eq!(map.get("a").map(String::as_str), Some("1")); + assert_eq!(map.get("b").map(String::as_str), Some("2")); + } + other => panic!("expected FormData body, got {:?}", other), + } + } + + #[test] + fn parses_multipart_form() { + let parsed = parse_curl( + "curl https://api.example.com -F 'name=alice' -F 'email=alice@example.com'", + ) + .unwrap(); + match &parsed.body { + RequestBody::MultipartFormData(fields) => { + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].key, "name"); + assert_eq!(fields[0].value, "alice"); + } + other => panic!("expected MultipartFormData body, got {:?}", other), + } + } + + #[test] + fn parses_basic_auth() { + let parsed = parse_curl("curl -u alice:secret https://api.example.com").unwrap(); + let auth = parsed + .headers + .iter() + .find(|h| h.key.eq_ignore_ascii_case("Authorization")) + .expect("auth header"); + assert!(auth.value.starts_with("Basic ")); + } + + #[test] + fn handles_location_flag() { + let parsed = + parse_curl("curl --location 'https://ssp.veonadx.com/bid/prebid'").unwrap(); + assert_eq!(parsed.url, "https://ssp.veonadx.com/bid/prebid"); + } + + #[test] + fn parses_real_world_multiline_curl() { + let cmd = r#"curl --location 'https://ssp.veonadx.com/bid/prebid' \ +--header 'Content-Type: application/json' \ +--data '{ + "id": "abc", + "test": 1 +}'"#; + let parsed = parse_curl(cmd).unwrap(); + assert_eq!(parsed.method, HttpMethod::Post); + assert_eq!(parsed.url, "https://ssp.veonadx.com/bid/prebid"); + match parsed.body { + RequestBody::Json(s) => { + assert!(s.contains("\"id\"")); + assert!(s.contains("\"test\"")); + } + other => panic!("expected JSON body, got {:?}", other), + } + } + + #[test] + fn parses_user_provided_complex_curl() { + let cmd = r#"curl --location 'https://ssp.veonadx.com/bid/prebid' \ +--header 'Content-Type: application/json' \ +--data '{ + "imp": [ + { + "id": "18e78361", + "ext": { + "prebid": { + "is_rewarded_inventory": 1 + } + } + } + ], + "id": "18e78361", + "regs": { + "ext": { + "gdpr": 1 + } + } +}'"#; + let parsed = parse_curl(cmd).unwrap(); + assert_eq!(parsed.method, HttpMethod::Post); + assert_eq!(parsed.url, "https://ssp.veonadx.com/bid/prebid"); + assert_eq!(parsed.headers.len(), 1); + assert_eq!(parsed.headers[0].key, "Content-Type"); + assert_eq!(parsed.headers[0].value, "application/json"); + match parsed.body { + RequestBody::Json(s) => { + assert!(s.contains("\"imp\"")); + assert!(s.contains("\"gdpr\"")); + } + other => panic!("expected JSON body, got {:?}", other), + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 8c23dee..7ed5879 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,7 @@ +mod curl_parser; mod editor; mod runtime; +pub use curl_parser::{looks_like_curl, parse_curl, ParsedCurl}; pub use editor::trigger_editor_search; pub use runtime::{shared_tokio_runtime, DebouncedJsonWriter}; diff --git a/src/views/main_view.rs b/src/views/main_view.rs index adb1b72..c14176f 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -184,11 +184,65 @@ impl MainView { if tab.url_input.is_none() { let url_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter request URL...")); + Self::subscribe_url_input_for_curl(&url_input, window, cx); tab.url_input = Some(url_input); } } } + /// Subscribe to URL input changes to detect pasted curl commands. + fn subscribe_url_input_for_curl( + url_input: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + cx.subscribe_in(url_input, window, |this, state, event, window, cx| { + use gpui_component::input::InputEvent; + if !matches!(event, InputEvent::Change) { + return; + } + let text = state.read(cx).text().to_string(); + if !crate::utils::looks_like_curl(&text) { + return; + } + match crate::utils::parse_curl(&text) { + Ok(parsed) => { + let url_value = parsed.url.clone(); + state.update(cx, |s, cx| { + s.set_value(url_value, window, cx); + }); + this.apply_parsed_curl_to_active_tab(&parsed, window, cx); + } + Err(err) => { + log::warn!("Failed to parse curl from URL bar: {}", err); + } + } + }) + .detach(); + } + + /// Apply a parsed curl to the currently-active tab. + fn apply_parsed_curl_to_active_tab( + &mut self, + parsed: &crate::utils::ParsedCurl, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.get(self.active_tab_index) else { + return; + }; + let method_dropdown = tab.method_dropdown.clone(); + let request_view = tab.request_view.clone(); + + method_dropdown.update(cx, |state, cx| { + state.set_method(parsed.method, cx); + }); + request_view.update(cx, |view, cx| { + view.apply_parsed_curl(parsed, window, cx); + }); + cx.notify(); + } + /// Ensure sidebar search inputs are initialized fn ensure_sidebar_inputs(&mut self, window: &mut Window, cx: &mut Context) { if self.history_search.is_none() { @@ -299,6 +353,7 @@ impl MainView { .placeholder("Enter request URL...") .default_value(&request_data.url) }); + Self::subscribe_url_input_for_curl(&url_input, window, cx); let tab = TabState { id: self.next_tab_id, @@ -425,6 +480,7 @@ impl MainView { .placeholder("Enter request URL...") .default_value(&request_data.url) }); + Self::subscribe_url_input_for_curl(&url_input, window, cx); let tab = TabState { id: self.next_tab_id, @@ -1798,11 +1854,13 @@ impl MainView { let new_method_dropdown = cx.new(|_| MethodDropdownState::new(old_method)); let new_url_input = if old_url_input.is_some() { - Some(cx.new(|cx| { + let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Enter request URL...") .default_value(&duplicated_url) - })) + }); + Self::subscribe_url_input_for_curl(&input, window, cx); + Some(input) } else { None }; diff --git a/src/views/request_view.rs b/src/views/request_view.rs index de0ed24..b54720c 100644 --- a/src/views/request_view.rs +++ b/src/views/request_view.rs @@ -578,6 +578,69 @@ impl RequestView { pub fn trigger_search(&mut self, window: &mut Window, _cx: &mut Context) { crate::utils::trigger_editor_search(self.body_editor.clone(), window); } + + /// Apply a parsed curl command to the request entity and editors. + pub fn apply_parsed_curl( + &mut self, + parsed: &crate::utils::ParsedCurl, + window: &mut Window, + cx: &mut Context, + ) { + let body_type = BodyType::from_request_body(&parsed.body); + + self.request.update(cx, |req, cx| { + req.set_method(parsed.method, cx); + req.set_url(parsed.url.clone(), cx); + req.set_headers(parsed.headers.clone(), cx); + req.set_body(parsed.body.clone(), cx); + }); + + self.body_type = body_type; + + if let Some(selector) = self.body_type_selector.clone() { + selector.update(cx, |s, cx| s.set_type(body_type, window, cx)); + } + + match &parsed.body { + RequestBody::Json(content) | RequestBody::Text(content) => { + let display = if matches!(parsed.body, RequestBody::Json(_)) { + serde_json::from_str::(content) + .ok() + .and_then(|v| serde_json::to_string_pretty(&v).ok()) + .unwrap_or_else(|| content.clone()) + } else { + content.clone() + }; + self.initial_body_content = Some(display.clone()); + self.ensure_body_editor(window, cx); + if let Some(editor) = self.body_editor.clone() { + editor.update(cx, |state, cx| { + state.set_value(display, window, cx); + }); + } + } + RequestBody::FormData(map) => { + self.form_data_editor = None; + self.initial_form_data = Some(map.clone()); + self.ensure_form_data_editor(window, cx); + } + RequestBody::MultipartFormData(fields) => { + self.multipart_form_data_editor = None; + self.initial_multipart_data = Some(fields.clone()); + self.ensure_multipart_form_data_editor(window, cx); + } + RequestBody::None => {} + } + + // Force header editor to rebuild from the request entity on next render. + self.header_editor = None; + + if !matches!(parsed.body, RequestBody::None) { + self.active_tab = RequestTab::Body; + } + + cx.notify(); + } } impl Focusable for RequestView {