Skip to content

Commit

Permalink
Make block strings parsing conform to GraphQL spec. (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
fpacanowski authored Dec 6, 2023
1 parent b2f6deb commit f75d96f
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 45 deletions.
116 changes: 86 additions & 30 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,41 +173,57 @@ where

fn unquote_block_string(src: &str) -> Result<String, Error<Token<'_>, 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<usize> = 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<String, Error<Token, Token>> {
Expand Down Expand Up @@ -390,6 +406,7 @@ where

#[cfg(test)]
mod tests {
use super::unquote_block_string;
use super::unquote_string;
use super::Number;

Expand Down Expand Up @@ -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);
}
}
6 changes: 1 addition & 5 deletions tests/queries/kitchen-sink_canonical.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
}

{
Expand Down
6 changes: 4 additions & 2 deletions tests/schemas/directive_descriptions.graphql
Original file line number Diff line number Diff line change
@@ -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(
"""
Expand All @@ -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(
"""
Expand Down
14 changes: 6 additions & 8 deletions tests/schemas/directive_descriptions_canonical.graphql
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit f75d96f

Please sign in to comment.