diff --git a/src/common.rs b/src/common.rs index 4deddac..3261df9 100644 --- a/src/common.rs +++ b/src/common.rs @@ -173,41 +173,57 @@ where fn unquote_block_string(src: &str) -> Result, Token<'_>>> { debug_assert!(src.starts_with("\"\"\"") && src.ends_with("\"\"\"")); - let indent = src[3..src.len() - 3] - .lines() - .skip(1) - .filter_map(|line| { - let trimmed = line.trim_start().len(); - if trimmed > 0 { - Some(line.len() - trimmed) - } else { - None // skip whitespace-only lines - } - }) - .min() - .unwrap_or(0); - let mut result = String::with_capacity(src.len() - 6); - let mut lines = src[3..src.len() - 3].lines(); - if let Some(first) = lines.next() { - let stripped = first.trim(); - if !stripped.is_empty() { - result.push_str(stripped); - result.push('\n'); + let lines = src[3..src.len() - 3].lines(); + + let mut common_indent = usize::MAX; + let mut first_non_empty_line: Option = None; + let mut last_non_empty_line = 0; + for (idx, line) in lines.clone().enumerate() { + let indent = line.len() - line.trim_start().len(); + if indent == line.len() { + continue; } - } - let mut last_line = 0; - for line in lines { - last_line = result.len(); - if line.len() > indent { - result.push_str(&line[indent..].replace(r#"\""""#, r#"""""#)); + + first_non_empty_line.get_or_insert(idx); + last_non_empty_line = idx; + + if idx != 0 { + common_indent = std::cmp::min(common_indent, indent); } - result.push('\n'); } - if result[last_line..].trim().is_empty() { - result.truncate(last_line); + + if first_non_empty_line.is_none() { + // The block string contains only whitespace. + return Ok("".to_string()); } + let first_non_empty_line = first_non_empty_line.unwrap(); + + let mut result = String::with_capacity(src.len() - 6); + let mut lines = lines + .enumerate() + // Skip leading and trailing empty lines. + .skip(first_non_empty_line) + .take(last_non_empty_line - first_non_empty_line + 1) + // Remove indent, except the first line. + .map(|(idx, line)| { + if idx != 0 && line.len() >= common_indent { + &line[common_indent..] + } else { + line + } + }) + // Handle escaped triple-quote (\"""). + .map(|x| x.replace(r#"\""""#, r#"""""#)); + + if let Some(line) = lines.next() { + result.push_str(&line); - Ok(result) + for line in lines { + result.push_str("\n"); + result.push_str(&line); + } + } + return Ok(result); } fn unquote_string(s: &str) -> Result> { @@ -390,6 +406,7 @@ where #[cfg(test)] mod tests { + use super::unquote_block_string; use super::unquote_string; use super::Number; @@ -422,4 +439,43 @@ mod tests { "\u{0009} hello \u{000A} there" ); } + + #[test] + fn block_string_leading_and_trailing_empty_lines() { + let block = &triple_quote(" \n\n Hello,\n World!\n\n Yours,\n GraphQL.\n\n\n"); + assert_eq!( + unquote_block_string(&block), + Result::Ok("Hello,\n World!\n\nYours,\n GraphQL.".to_string()) + ); + } + + #[test] + fn block_string_indent() { + let block = &triple_quote("Hello \n\n Hello,\n World!\n"); + assert_eq!( + unquote_block_string(&block), + Result::Ok("Hello \n\nHello,\n World!".to_string()) + ); + } + + #[test] + fn block_string_escaping() { + let block = triple_quote(r#"\""""#); + assert_eq!( + unquote_block_string(&block), + Result::Ok("\"\"\"".to_string()) + ); + } + + #[test] + fn block_string_empty() { + let block = triple_quote(""); + assert_eq!(unquote_block_string(&block), Result::Ok("".to_string())); + let block = triple_quote(" \n\t\n"); + assert_eq!(unquote_block_string(&block), Result::Ok("".to_string())); + } + + fn triple_quote(input: &str) -> String { + return format!("\"\"\"{}\"\"\"", input); + } } diff --git a/tests/queries/kitchen-sink_canonical.graphql b/tests/queries/kitchen-sink_canonical.graphql index 726ed42..aaecd85 100644 --- a/tests/queries/kitchen-sink_canonical.graphql +++ b/tests/queries/kitchen-sink_canonical.graphql @@ -41,11 +41,7 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {block: """ - - block string uses \""" - - """, key: "value"}) + foo(size: $size, bar: $b, obj: {block: "block string uses \"\"\"", key: "value"}) } { diff --git a/tests/schemas/directive_descriptions.graphql b/tests/schemas/directive_descriptions.graphql index b17eaac..67b1c8d 100644 --- a/tests/schemas/directive_descriptions.graphql +++ b/tests/schemas/directive_descriptions.graphql @@ -1,5 +1,6 @@ """ -Directs the executor to include this field or fragment only when the `if` argument is true. +Directs the executor to include this field or +fragment only when the `if` argument is true. """ directive @include( """ @@ -9,7 +10,8 @@ directive @include( ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ -Directs the executor to skip this field or fragment when the `if` argument is true. +Directs the executor to skip this field or +fragment when the `if` argument is true. """ directive @skip( """ diff --git a/tests/schemas/directive_descriptions_canonical.graphql b/tests/schemas/directive_descriptions_canonical.graphql index 6bc4d73..afabf0c 100644 --- a/tests/schemas/directive_descriptions_canonical.graphql +++ b/tests/schemas/directive_descriptions_canonical.graphql @@ -1,13 +1,11 @@ """ - Directs the executor to include this field or fragment only when the `if` argument is true. + Directs the executor to include this field or + fragment only when the `if` argument is true. """ -directive @include(""" - Included when true. -""" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @include("Included when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ - Directs the executor to skip this field or fragment when the `if` argument is true. + Directs the executor to skip this field or + fragment when the `if` argument is true. """ -directive @skip(""" - Skipped when true. -""" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @skip("Skipped when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT