From 884f187c49a190984f6c7ca3f3d4123cb1e04ef4 Mon Sep 17 00:00:00 2001 From: Simon Sapin Date: Mon, 6 Nov 2023 12:37:36 +0100 Subject: [PATCH] Serialize multi-line strings as block strings Fixes https://github.com/apollographql/apollo-rs/issues/664 --- crates/apollo-compiler/src/ast/serialize.rs | 99 +++++++++++++++++++ .../serializer/ok/0015_supergraph.graphql | 6 +- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/apollo-compiler/src/ast/serialize.rs b/crates/apollo-compiler/src/ast/serialize.rs index fab07b720..40c185a18 100644 --- a/crates/apollo-compiler/src/ast/serialize.rs +++ b/crates/apollo-compiler/src/ast/serialize.rs @@ -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() } @@ -799,6 +812,9 @@ fn curly_brackets_space_separated( } 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) @@ -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; + } + + /// + 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) -> fmt::Result { if let Some(description) = description { serialize_string_value(state, description)?; diff --git a/crates/apollo-compiler/test_data/serializer/ok/0015_supergraph.graphql b/crates/apollo-compiler/test_data/serializer/ok/0015_supergraph.graphql index 48caa2de9..e1cb5427e 100644 --- a/crates/apollo-compiler/test_data/serializer/ok/0015_supergraph.graphql +++ b/crates/apollo-compiler/test_data/serializer/ok/0015_supergraph.graphql @@ -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)