Skip to content

Commit

Permalink
Serialize multi-line strings as block strings
Browse files Browse the repository at this point in the history
Fixes #664
  • Loading branch information
SimonSapin committed Nov 6, 2023
1 parent 1ff6468 commit 884f187
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 1 deletion.
99 changes: 99 additions & 0 deletions crates/apollo-compiler/src/ast/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ impl<'config, 'fmt, 'fmt2> State<'config, 'fmt, 'fmt2> {
Ok(())
}

/// Panics if newlines are disabled
fn require_new_line(&mut self) -> fmt::Result {
let prefix = self
.config
.ident_prefix
.expect("require_new_line called with newlines disabled");
self.write("\n")?;
for _ in 0..self.indent_level {
self.write(prefix)?;
}
Ok(())
}

pub(crate) fn newlines_enabled(&self) -> bool {
self.config.ident_prefix.is_some()
}
Expand Down Expand Up @@ -799,6 +812,9 @@ fn curly_brackets_space_separated<T>(
}

fn serialize_string_value(state: &mut State, mut str: &str) -> fmt::Result {
if state.newlines_enabled() && prefer_block_string(str) && can_be_block_string(str) {
return serialize_block_string(state, str);
}
// TODO: use block string when it would be possible AND would look nicer
// The parsed value of a block string cannot have:
// * non-\n control characters (not representable)
Expand Down Expand Up @@ -829,6 +845,89 @@ fn serialize_string_value(state: &mut State, mut str: &str) -> fmt::Result {
state.write("\"")
}

fn serialize_block_string(state: &mut State, str: &str) -> fmt::Result {
const TRIPLE_QUOTE: &str = "\"\"\"";
const ESCAPED_TRIPLE_QUOTE: &str = "\\\"\"\"";
const _: () = assert!(TRIPLE_QUOTE.len() == 3);
const _: () = assert!(ESCAPED_TRIPLE_QUOTE.len() == 4);

state.write(TRIPLE_QUOTE)?;
// `can_be_block_string` excludes \r, so the only remaining line terminator is \n
for mut line in str.split('\n') {
if line.is_empty() {
// Skip indentation which would be trailing whitespace
state.write("\n")?;
continue;
}
state.require_new_line()?;
loop {
if let Some((before, after)) = line.split_once(TRIPLE_QUOTE) {
state.write(before)?;
state.write(ESCAPED_TRIPLE_QUOTE)?;
line = after;
} else {
state.write(line)?;
break;
}
}
}
state.require_new_line()?;
state.write(TRIPLE_QUOTE)
}

fn prefer_block_string(value: &str) -> bool {
// If a value is multi-line, it likely looks nicer as a block string
value.contains('\n')
}

/// Is it possible to create a serialization that, when fed through
/// [BlockStringValue](https://spec.graphql.org/October2021/#BlockStringValue()),
/// returns exactly `value`?
fn can_be_block_string(value: &str) -> bool {
// `BlockStringValue` splits its inputs at any `LineTerminator` (\n, \r\n, or \r)
// and eventually joins lines but always with \n. So its output can never contain \r
if value.contains('\r') {
return false;
}

/// <https://spec.graphql.org/October2021/#WhiteSpace>
fn trim_start_graphql_whitespace(value: &str) -> &str {
value.trim_start_matches([' ', '\t'])
}

// With the above, \n is the only remaining LineTerminator
let mut lines = value.split('\n');
if lines
.next()
.is_some_and(|first| trim_start_graphql_whitespace(first).is_empty())
|| lines
.next_back()
.is_some_and(|last| trim_start_graphql_whitespace(last).is_empty())
{
// Leading or trailing whitespace-only line would be trimmed by `BlockStringValue`
return false;
}

let common_indent = {
let mut lines = value.split('\n');
// Skip the first line, the one following """
lines.next();

let each_line_indent_utf8_len = lines.filter_map(|line| {
let after_indent = trim_start_graphql_whitespace(line);
if !after_indent.is_empty() {
Some(line.len() - after_indent.len())
} else {
None // skip whitespace-only lines
}
});
each_line_indent_utf8_len.min().unwrap_or(0)
};
// If there is common indent `BlockStringValue` would remove it
// and incorrectly round-trip to a different value.
common_indent == 0
}

fn serialize_description(state: &mut State, description: &Option<NodeStr>) -> fmt::Result {
if let Some(description) = description {
serialize_string_value(state, description)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ type Book implements Product @join__owner(graph: BOOKS) @join__type(graph: BOOKS
year: Int @join__field(graph: BOOKS)
similarBooks: [Book]! @join__field(graph: BOOKS)
metadata: [MetadataOrError] @join__field(graph: BOOKS)
"\"\"\"To be in stock or not to be in stock\"\"\"\n\nthat is the question"
"""
\"""To be in stock or not to be in stock\"""
that is the question
"""
inStock: Boolean @join__field(graph: INVENTORY)
isCheckedOut: Boolean @join__field(graph: INVENTORY)
upc: String! @join__field(graph: PRODUCT)
Expand Down

0 comments on commit 884f187

Please sign in to comment.