diff --git a/crates/yaak-common/src/serde.rs b/crates/yaak-common/src/serde.rs index 683cc25d..49146aaa 100644 --- a/crates/yaak-common/src/serde.rs +++ b/crates/yaak-common/src/serde.rs @@ -21,3 +21,10 @@ pub fn get_str_map<'a>(v: &'a BTreeMap, key: &str) -> &'a str { Some(v) => v.as_str().unwrap_or_default(), } } + +pub fn get_bool_map(v: &BTreeMap, key: &str, fallback: bool) -> bool { + match v.get(key) { + None => fallback, + Some(v) => v.as_bool().unwrap_or(fallback), + } +} diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index 1131b58c..2d5b059d 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -9,9 +9,9 @@ use std::collections::BTreeMap; use std::pin::Pin; use std::time::Duration; use tokio::io::AsyncRead; -use yaak_common::serde::{get_bool, get_str, get_str_map}; +use yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map}; use yaak_models::models::HttpRequest; -use yaak_templates::strip_json_comments::strip_json_comments; +use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments}; pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary"; @@ -195,7 +195,7 @@ async fn build_body( (build_form_body(&body), Some("application/x-www-form-urlencoded".to_string())) } "multipart/form-data" => build_multipart_body(&body, &headers).await?, - _ if body.contains_key("text") => (build_text_body(&body), None), + _ if body.contains_key("text") => (build_text_body(&body, body_type), None), t => { warn!("Unsupported body type: {}", t); (None, None) @@ -270,13 +270,20 @@ async fn build_binary_body( })) } -fn build_text_body(body: &BTreeMap) -> Option { +fn build_text_body(body: &BTreeMap, body_type: &str) -> Option { let text = get_str_map(body, "text"); if text.is_empty() { - None - } else { - Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string()))) + return None; } + + let send_comments = get_bool_map(body, "sendJsonComments", false); + let text = if !send_comments && body_type == "application/json" { + maybe_strip_json_comments(text) + } else { + text.to_string() + }; + + Some(SendableBodyWithMeta::Bytes(Bytes::from(text))) } fn build_graphql_body( @@ -702,7 +709,7 @@ mod tests { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("Hello, World!")); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { assert_eq!(bytes, Bytes::from("Hello, World!")) @@ -716,7 +723,7 @@ mod tests { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("")); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } @@ -724,10 +731,57 @@ mod tests { async fn test_text_body_missing() { let body = BTreeMap::new(); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } + #[tokio::test] + async fn test_text_body_strips_json_comments_by_default() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); + + let result = build_text_body(&body, "application/json"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(!text.contains("// comment")); + assert!(text.contains("\"foo\": \"bar\"")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_text_body_send_json_comments_when_opted_in() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); + body.insert("sendJsonComments".to_string(), json!(true)); + + let result = build_text_body(&body, "application/json"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("// comment")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_text_body_no_strip_for_non_json() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("// not json\nsome text")); + + let result = build_text_body(&body, "text/plain"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("// not json")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + #[tokio::test] async fn test_form_urlencoded_body() -> Result<()> { let mut body = BTreeMap::new(); diff --git a/crates/yaak-templates/src/format_json.rs b/crates/yaak-templates/src/format_json.rs index b58324f6..98f1a90e 100644 --- a/crates/yaak-templates/src/format_json.rs +++ b/crates/yaak-templates/src/format_json.rs @@ -11,6 +11,7 @@ pub fn format_json(text: &str, tab: &str) -> String { let mut new_json = "".to_string(); let mut depth = 0; let mut state = FormatState::None; + let mut saw_newline_in_whitespace = false; loop { let rest_of_chars = chars.clone(); @@ -74,8 +75,8 @@ pub fn format_json(text: &str, tab: &str) -> String { } // Check if the comma handler already added \n + indent let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\t'); - if trimmed.ends_with(",\n") { - // After comma: undo the newline+indent, make comment trailing + if trimmed.ends_with(",\n") && !saw_newline_in_whitespace { + // Trailing comment on the same line as comma (e.g. "foo",// comment) new_json.truncate(trimmed.len() - 1); new_json.push(' '); } else if !trimmed.ends_with('\n') && !new_json.is_empty() { @@ -85,6 +86,7 @@ pub fn format_json(text: &str, tab: &str) -> String { new_json.push_str(&comment); new_json.push('\n'); new_json.push_str(tab.to_string().repeat(depth).as_str()); + saw_newline_in_whitespace = false; continue; } @@ -180,8 +182,12 @@ pub fn format_json(text: &str, tab: &str) -> String { || current_char == '\t' || current_char == '\r' { + if current_char == '\n' { + saw_newline_in_whitespace = true; + } // Don't add these } else { + saw_newline_in_whitespace = false; new_json.push(current_char); } } @@ -500,6 +506,26 @@ mod tests { "foo": "// not a comment", "bar": "/* also not */" } +"# + .trim() + ); + } + + #[test] + fn test_comment_on_line_after_comma() { + assert_eq!( + format_json( + r#"{ + "a": "aaa", + // "b": "bbb" +}"#, + " " + ), + r#" +{ + "a": "aaa", + // "b": "bbb" +} "# .trim() ); diff --git a/crates/yaak-templates/src/strip_json_comments.rs b/crates/yaak-templates/src/strip_json_comments.rs index 024d4825..ade19c8c 100644 --- a/crates/yaak-templates/src/strip_json_comments.rs +++ b/crates/yaak-templates/src/strip_json_comments.rs @@ -113,11 +113,67 @@ pub fn strip_json_comments(text: &str) -> String { } // Remove lines that are now empty (were comment-only lines) - result + let result = result .lines() .filter(|line| !line.trim().is_empty()) .collect::>() - .join("\n") + .join("\n"); + + // Remove trailing commas before } or ] + strip_trailing_commas(&result) +} + +/// Removes trailing commas before closing braces/brackets, respecting strings. +fn strip_trailing_commas(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let mut i = 0; + let mut in_string = false; + + while i < chars.len() { + let ch = chars[i]; + + if in_string { + result.push(ch); + match ch { + '"' => in_string = false, + '\\' => { + i += 1; + if i < chars.len() { + result.push(chars[i]); + } + } + _ => {} + } + i += 1; + continue; + } + + if ch == '"' { + in_string = true; + result.push(ch); + i += 1; + continue; + } + + if ch == ',' { + // Look ahead past whitespace/newlines for } or ] + let mut j = i + 1; + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + if j < chars.len() && (chars[j] == '}' || chars[j] == ']') { + // Skip the comma + i += 1; + continue; + } + } + + result.push(ch); + i += 1; + } + + result } #[cfg(test)] @@ -232,4 +288,31 @@ mod tests { }"# ); } + + #[test] + fn test_trailing_comma_after_comment_removed() { + assert_eq!( + strip_json_comments(r#"{ + "a": "aaa", + // "b": "bbb" +}"#), + r#"{ + "a": "aaa" +}"# + ); + } + + #[test] + fn test_trailing_comma_in_array() { + assert_eq!( + strip_json_comments(r#"[1, 2, /* 3 */]"#), + r#"[1, 2]"# + ); + } + + #[test] + fn test_comma_inside_string_preserved() { + let input = r#"{"a": "hello,}"#; + assert_eq!(strip_json_comments(input), input); + } } diff --git a/src-web/components/HttpRequestPane.tsx b/src-web/components/HttpRequestPane.tsx index 70300900..75f857b3 100644 --- a/src-web/components/HttpRequestPane.tsx +++ b/src-web/components/HttpRequestPane.tsx @@ -48,6 +48,7 @@ import { FormMultipartEditor } from './FormMultipartEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; +import { JsonBodyEditor } from './JsonBodyEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { RequestMethodDropdown } from './RequestMethodDropdown'; import { UrlBar } from './UrlBar'; @@ -257,7 +258,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const handleBodyTextChange = useCallback( - (text: string) => patchModel(activeRequest, { body: { text } }), + (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), [activeRequest], ); @@ -370,16 +371,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: {activeRequest.bodyType === BODY_TYPE_JSON ? ( - ) : activeRequest.bodyType === BODY_TYPE_XML ? ( patchModel(request, { body: { ...request.body, text } }), + [request], + ); + + const autoFix = request.body?.sendJsonComments !== true; + + const lintExtension = useMemo( + () => + linter(jsonParseLinter(autoFix ? { allowComments: true, allowTrailingCommas: true } : {})), + [autoFix], + ); + + const hasComments = useMemo( + () => textLikelyContainsJsonComments(request.body?.text ?? ''), + [request.body?.text], + ); + + const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue({ + namespace: 'no_sync', + key: ['json-fix-3', request.workspaceId], + fallback: false, + }); + + const handleToggleAutoFix = useCallback(() => { + const newBody = { ...request.body }; + if (autoFix) { + newBody.sendJsonComments = true; + } else { + delete newBody.sendJsonComments; + } + patchModel(request, { body: newBody }); + }, [request, autoFix]); + + const handleDropdownOpen = useCallback(() => { + if (!bannerDismissed) { + setBannerDismissed(true); + } + }, [bannerDismissed, setBannerDismissed]); + + const showBanner = hasComments && autoFix && !bannerDismissed; + + const stripMessage = 'Automatically strip comments and trailing commas before sending'; + const actions = useMemo( + () => [ + showBanner && ( + +

+ Auto-fix enabled + +

+
+ ), +
+ , + leftSlot: ( + + ), + }, + ] satisfies DropdownItem[] + } + > + + +
, + ], + [handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner], + ); + + return ( + + ); +} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 829211fe..f5d45794 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -78,6 +78,7 @@ export interface EditorProps { hideGutter?: boolean; id?: string; language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null; + lintExtension?: Extension; graphQLSchema?: GraphQLSchema | null; onBlur?: () => void; onChange?: (value: string) => void; @@ -124,6 +125,7 @@ function EditorInner({ hideGutter, graphQLSchema, language, + lintExtension, onBlur, onChange, onFocus, @@ -332,6 +334,7 @@ function EditorInner({ const ext = getLanguageExtension({ useTemplating, language, + lintExtension, hideGutter, environmentVariables, autocomplete, @@ -344,6 +347,7 @@ function EditorInner({ view.dispatch({ effects: languageCompartment.reconfigure(ext) }); }, [ language, + lintExtension, autocomplete, environmentVariables, onClickFunction, @@ -371,6 +375,7 @@ function EditorInner({ const langExt = getLanguageExtension({ useTemplating, language, + lintExtension, completionOptions, autocomplete, environmentVariables, diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 64e07656..8db6750d 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -8,7 +8,6 @@ import { history, historyKeymap } from '@codemirror/commands'; import { go } from '@codemirror/lang-go'; import { java } from '@codemirror/lang-java'; import { javascript } from '@codemirror/lang-javascript'; -import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc'; import { markdown } from '@codemirror/lang-markdown'; import { php } from '@codemirror/lang-php'; import { python } from '@codemirror/lang-python'; @@ -34,7 +33,6 @@ import { ruby } from '@codemirror/legacy-modes/mode/ruby'; import { shell } from '@codemirror/legacy-modes/mode/shell'; import { swift } from '@codemirror/legacy-modes/mode/swift'; import { linter, lintGutter, lintKeymap } from '@codemirror/lint'; - import { search, searchKeymap } from '@codemirror/search'; import type { Extension } from '@codemirror/state'; import { EditorState } from '@codemirror/state'; @@ -50,6 +48,7 @@ import { rectangularSelection, } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; +import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc'; import { graphql } from 'cm6-graphql'; import type { GraphQLSchema } from 'graphql'; import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId'; @@ -61,13 +60,13 @@ import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms'; import type { EditorProps } from './Editor'; import { jsonParseLinter } from './json-lint'; import { pairs } from './pairs/extension'; +import { searchMatchCount } from './searchMatchCount'; import { text } from './text/extension'; import { timeline } from './timeline/extension'; import type { TwigCompletionOption } from './twig/completion'; import { twig } from './twig/extension'; import { pathParametersPlugin } from './twig/pathParameters'; import { url } from './url/extension'; -import { searchMatchCount } from './searchMatchCount'; export const syntaxHighlightStyle = HighlightStyle.define([ { @@ -140,6 +139,7 @@ const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript export function getLanguageExtension({ useTemplating, language = 'text', + lintExtension, environmentVariables, autocomplete, hideGutter, @@ -156,7 +156,7 @@ export function getLanguageExtension({ onClickPathParameter: (name: string) => void; completionOptions: TwigCompletionOption[]; graphQLSchema: GraphQLSchema | null; -} & Pick) { +} & Pick) { const extraExtensions: Extension[] = []; if (language === 'url') { @@ -193,7 +193,7 @@ export function getLanguageExtension({ } if (language === 'json') { - extraExtensions.push(linter(jsonParseLinter())); + extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); extraExtensions.push( jsoncLanguage.data.of({ commentTokens: { line: '//', block: { open: '/*', close: '*/' } }, diff --git a/src-web/components/core/Editor/json-lint.ts b/src-web/components/core/Editor/json-lint.ts index 35c33904..32652f15 100644 --- a/src-web/components/core/Editor/json-lint.ts +++ b/src-web/components/core/Editor/json-lint.ts @@ -4,14 +4,22 @@ import { parse as jsonLintParse } from '@prantlf/jsonlint'; const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g; -export function jsonParseLinter() { +interface JsonLintOptions { + allowComments?: boolean; + allowTrailingCommas?: boolean; +} + +export function jsonParseLinter(options?: JsonLintOptions) { return (view: EditorView): Diagnostic[] => { try { const doc = view.state.doc.toString(); // We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template // syntax with repeating `1` characters, so it's valid JSON and the position is still correct. const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length)); - jsonLintParse(escapedDoc, { mode: 'cjson' }); + jsonLintParse(escapedDoc, { + mode: (options?.allowComments ?? true) ? 'cjson' : 'json', + ignoreTrailingCommas: options?.allowTrailingCommas ?? false, + }); // biome-ignore lint/suspicious/noExplicitAny: none } catch (err: any) { if (!('location' in err)) { diff --git a/src-web/components/graphql/GraphQLEditor.tsx b/src-web/components/graphql/GraphQLEditor.tsx index 11b3c879..1db31b0a 100644 --- a/src-web/components/graphql/GraphQLEditor.tsx +++ b/src-web/components/graphql/GraphQLEditor.tsx @@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp { type: 'separator', label: 'Setting' }, { label: 'Automatic Introspection', + keepOpenOnSelect: true, onSelect: () => { setAutoIntrospectDisabled({ ...autoIntrospectDisabled, diff --git a/src-web/lib/jsonComments.ts b/src-web/lib/jsonComments.ts new file mode 100644 index 00000000..3c308955 --- /dev/null +++ b/src-web/lib/jsonComments.ts @@ -0,0 +1,30 @@ +/** + * Simple heuristic to detect if a string likely contains JSON/JSONC comments. + * Checks for // and /* patterns that are NOT inside double-quoted strings. + * Used for UI hints only — doesn't need to be perfect. + */ +export function textLikelyContainsJsonComments(text: string): boolean { + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (ch === '"') { + inString = false; + } else if (ch === '\\') { + i++; // skip escaped char + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '/' && i + 1 < text.length) { + const next = text[i + 1]; + if (next === '/' || next === '*') { + return true; + } + } + } + return false; +}