Skip to content

Commit f75d96f

Browse files
authored
Make block strings parsing conform to GraphQL spec. (#75)
1 parent b2f6deb commit f75d96f

File tree

4 files changed

+97
-45
lines changed

4 files changed

+97
-45
lines changed

src/common.rs

+86-30
Original file line numberDiff line numberDiff line change
@@ -173,41 +173,57 @@ where
173173

174174
fn unquote_block_string(src: &str) -> Result<String, Error<Token<'_>, Token<'_>>> {
175175
debug_assert!(src.starts_with("\"\"\"") && src.ends_with("\"\"\""));
176-
let indent = src[3..src.len() - 3]
177-
.lines()
178-
.skip(1)
179-
.filter_map(|line| {
180-
let trimmed = line.trim_start().len();
181-
if trimmed > 0 {
182-
Some(line.len() - trimmed)
183-
} else {
184-
None // skip whitespace-only lines
185-
}
186-
})
187-
.min()
188-
.unwrap_or(0);
189-
let mut result = String::with_capacity(src.len() - 6);
190-
let mut lines = src[3..src.len() - 3].lines();
191-
if let Some(first) = lines.next() {
192-
let stripped = first.trim();
193-
if !stripped.is_empty() {
194-
result.push_str(stripped);
195-
result.push('\n');
176+
let lines = src[3..src.len() - 3].lines();
177+
178+
let mut common_indent = usize::MAX;
179+
let mut first_non_empty_line: Option<usize> = None;
180+
let mut last_non_empty_line = 0;
181+
for (idx, line) in lines.clone().enumerate() {
182+
let indent = line.len() - line.trim_start().len();
183+
if indent == line.len() {
184+
continue;
196185
}
197-
}
198-
let mut last_line = 0;
199-
for line in lines {
200-
last_line = result.len();
201-
if line.len() > indent {
202-
result.push_str(&line[indent..].replace(r#"\""""#, r#"""""#));
186+
187+
first_non_empty_line.get_or_insert(idx);
188+
last_non_empty_line = idx;
189+
190+
if idx != 0 {
191+
common_indent = std::cmp::min(common_indent, indent);
203192
}
204-
result.push('\n');
205193
}
206-
if result[last_line..].trim().is_empty() {
207-
result.truncate(last_line);
194+
195+
if first_non_empty_line.is_none() {
196+
// The block string contains only whitespace.
197+
return Ok("".to_string());
208198
}
199+
let first_non_empty_line = first_non_empty_line.unwrap();
200+
201+
let mut result = String::with_capacity(src.len() - 6);
202+
let mut lines = lines
203+
.enumerate()
204+
// Skip leading and trailing empty lines.
205+
.skip(first_non_empty_line)
206+
.take(last_non_empty_line - first_non_empty_line + 1)
207+
// Remove indent, except the first line.
208+
.map(|(idx, line)| {
209+
if idx != 0 && line.len() >= common_indent {
210+
&line[common_indent..]
211+
} else {
212+
line
213+
}
214+
})
215+
// Handle escaped triple-quote (\""").
216+
.map(|x| x.replace(r#"\""""#, r#"""""#));
217+
218+
if let Some(line) = lines.next() {
219+
result.push_str(&line);
209220

210-
Ok(result)
221+
for line in lines {
222+
result.push_str("\n");
223+
result.push_str(&line);
224+
}
225+
}
226+
return Ok(result);
211227
}
212228

213229
fn unquote_string(s: &str) -> Result<String, Error<Token, Token>> {
@@ -390,6 +406,7 @@ where
390406

391407
#[cfg(test)]
392408
mod tests {
409+
use super::unquote_block_string;
393410
use super::unquote_string;
394411
use super::Number;
395412

@@ -422,4 +439,43 @@ mod tests {
422439
"\u{0009} hello \u{000A} there"
423440
);
424441
}
442+
443+
#[test]
444+
fn block_string_leading_and_trailing_empty_lines() {
445+
let block = &triple_quote(" \n\n Hello,\n World!\n\n Yours,\n GraphQL.\n\n\n");
446+
assert_eq!(
447+
unquote_block_string(&block),
448+
Result::Ok("Hello,\n World!\n\nYours,\n GraphQL.".to_string())
449+
);
450+
}
451+
452+
#[test]
453+
fn block_string_indent() {
454+
let block = &triple_quote("Hello \n\n Hello,\n World!\n");
455+
assert_eq!(
456+
unquote_block_string(&block),
457+
Result::Ok("Hello \n\nHello,\n World!".to_string())
458+
);
459+
}
460+
461+
#[test]
462+
fn block_string_escaping() {
463+
let block = triple_quote(r#"\""""#);
464+
assert_eq!(
465+
unquote_block_string(&block),
466+
Result::Ok("\"\"\"".to_string())
467+
);
468+
}
469+
470+
#[test]
471+
fn block_string_empty() {
472+
let block = triple_quote("");
473+
assert_eq!(unquote_block_string(&block), Result::Ok("".to_string()));
474+
let block = triple_quote(" \n\t\n");
475+
assert_eq!(unquote_block_string(&block), Result::Ok("".to_string()));
476+
}
477+
478+
fn triple_quote(input: &str) -> String {
479+
return format!("\"\"\"{}\"\"\"", input);
480+
}
425481
}

tests/queries/kitchen-sink_canonical.graphql

+1-5
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,7 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) {
4141
}
4242

4343
fragment frag on Friend {
44-
foo(size: $size, bar: $b, obj: {block: """
45-
46-
block string uses \"""
47-
48-
""", key: "value"})
44+
foo(size: $size, bar: $b, obj: {block: "block string uses \"\"\"", key: "value"})
4945
}
5046

5147
{

tests/schemas/directive_descriptions.graphql

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""
2-
Directs the executor to include this field or fragment only when the `if` argument is true.
2+
Directs the executor to include this field or
3+
fragment only when the `if` argument is true.
34
"""
45
directive @include(
56
"""
@@ -9,7 +10,8 @@ directive @include(
910
) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
1011

1112
"""
12-
Directs the executor to skip this field or fragment when the `if` argument is true.
13+
Directs the executor to skip this field or
14+
fragment when the `if` argument is true.
1315
"""
1416
directive @skip(
1517
"""
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
"""
2-
Directs the executor to include this field or fragment only when the `if` argument is true.
2+
Directs the executor to include this field or
3+
fragment only when the `if` argument is true.
34
"""
4-
directive @include("""
5-
Included when true.
6-
""" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
5+
directive @include("Included when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
76

87
"""
9-
Directs the executor to skip this field or fragment when the `if` argument is true.
8+
Directs the executor to skip this field or
9+
fragment when the `if` argument is true.
1010
"""
11-
directive @skip("""
12-
Skipped when true.
13-
""" if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
11+
directive @skip("Skipped when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

0 commit comments

Comments
 (0)