Repository: graphql/graphql-js (ts)

Issue:
printSchema: creates invalid block strings for descriptions
Not every string can be represented as block string but currently `printSchema` uses block strings for all descriptions.
Example:
```js
const { buildSchema, parse, printSchema } = require('./dist');
const schema = buildSchema('" " scalar Foo');
const printedSDL = printSchema(schema);
console.log(printedSDL);
// """ """
// scalar Foo

console.log(printSchema(buildSchema(printedSDL)));
// """"""
// scalar Foo
```

We should add a new utility function (in `blockString.js`) that checks if the string can be represented as a block string and use it inside `printSchema.js`.
Based on our previous experience with block strings it would be hard to cover all edge cases so I strongly suggest writing fuzzing test for this new function, similar to this one:
https://github.com/graphql/graphql-js/blob/47ac276f21598db5690f287f2f0ea41bbdeef4ca/src/language/__tests__/blockString-fuzz.js#L24

Pull request description:
printSchema: handle descriptions that are non-printable as block strings
Fixes #2577
Fixes #2589

cc @Cito

Patch:
diff --git a/src/language/blockString.ts b/src/language/blockString.ts
index eeb60192..1c200c18 100644
--- a/src/language/blockString.ts
+++ b/src/language/blockString.ts
@@ -1,3 +1,5 @@
+import { isWhiteSpace } from './characterClasses';
+
 /**
  * Produces the value of a block string from its parsed raw value, similar to
  * CoffeeScript's block string, Python's docstring trim or Ruby's strip_heredoc.
@@ -6,82 +8,107 @@
  *
  * @internal
  */
-export function dedentBlockStringValue(rawString: string): string {
-  // Expand a block string's raw value into independent lines.
-  const lines = rawString.split(/\r\n|[\n\r]/g);
+export function dedentBlockStringLines(
+  lines: ReadonlyArray<string>,
+): Array<string> {
+  let commonIndent = Number.MAX_SAFE_INTEGER;
+  let firstNonEmptyLine = null;
+  let lastNonEmptyLine = -1;
 
-  // Remove common indentation from all lines but first.
-  const commonIndent = getBlockStringIndentation(rawString);
+  for (let i = 0; i < lines.length; ++i) {
+    const line = lines[i];
+    const indent = leadingWhitespace(line);
 
-  if (commonIndent !== 0) {
-    for (let i = 1; i < lines.length; i++) {
-      lines[i] = lines[i].slice(commonIndent);
+    if (indent === line.length) {
+      continue; // skip empty lines
     }
-  }
 
-  // Remove leading and trailing blank lines.
-  let startLine = 0;
-  while (startLine < lines.length && isBlank(lines[startLine])) {
-    ++startLine;
-  }
+    firstNonEmptyLine = firstNonEmptyLine ?? i;
+    lastNonEmptyLine = i;
 
-  let endLine = lines.length;
-  while (endLine > startLine && isBlank(lines[endLine - 1])) {
-    --endLine;
+    if (i !== 0 && indent < commonIndent) {
+      commonIndent = indent;
+    }
   }
 
-  // Return a string of the lines joined with U+000A.
-  return lines.slice(startLine, endLine).join('\n');
+  return (
+    lines
+      // Remove common indentation from all lines but first.
+      .map((line, i) => (i === 0 ? line : line.slice(commonIndent)))
+      // Remove leading and trailing blank lines.
+      .slice(firstNonEmptyLine ?? 0, lastNonEmptyLine + 1)
+  );
 }
 
-function isBlank(str: string): boolean {
-  for (const char of str) {
-    if (char !== ' ' && char !== '\t') {
-      return false;
-    }
+function leadingWhitespace(str: string): number {
+  let i = 0;
+  while (i < str.length && isWhiteSpace(str.charCodeAt(i))) {
+    ++i;
   }
-
-  return true;
+  return i;
 }
 
 /**
  * @internal
  */
-export function getBlockStringIndentation(value: string): number {
-  let isFirstLine = true;
+export function isPrintableAsBlockString(value: string): boolean {
+  if (value === '') {
+    return true; // empty string is printable
+  }
+
   let isEmptyLine = true;
-  let indent = 0;
-  let commonIndent = null;
+  let hasIndent = false;
+  let hasCommonIndent = true;
+  let seenNonEmptyLine = false;
 
   for (let i = 0; i < value.length; ++i) {
-    switch (value.charCodeAt(i)) {
-      case 13: //  \r
-        if (value.charCodeAt(i + 1) === 10) {
-          ++i; // skip \r\n as one symbol
-        }
-      // falls through
+    switch (value.codePointAt(i)) {
+      case 0x0000:
+      case 0x0001:
+      case 0x0002:
+      case 0x0003:
+      case 0x0004:
+      case 0x0005:
+      case 0x0006:
+      case 0x0007:
+      case 0x0008:
+      case 0x000b:
+      case 0x000c:
+      case 0x000e:
+      case 0x000f:
+        return false; // Has non-printable characters
+
+      case 0x000d: //  \r
+        return false; // Has \r or \r\n which will be replaced as \n
+
       case 10: //  \n
-        isFirstLine = false;
+        if (isEmptyLine && !seenNonEmptyLine) {
+          return false; // Has leading new line
+        }
+        seenNonEmptyLine = true;
+
         isEmptyLine = true;
-        indent = 0;
+        hasIndent = false;
         break;
       case 9: //   \t
       case 32: //  <space>
-        ++indent;
+        hasIndent ||= isEmptyLine;
         break;
       default:
-        if (
-          isEmptyLine &&
-          !isFirstLine &&
-          (commonIndent === null || indent < commonIndent)
-        ) {
-          commonIndent = indent;
-        }
+        hasCommonIndent &&= hasIndent;
         isEmptyLine = false;
     }
   }
 
-  return commonIndent ?? 0;
+  if (isEmptyLine) {
+    return false; // Has trailing empty lines
+  }
+
+  if (hasCommonIndent && seenNonEmptyLine) {
+    return false; // Has internal indent
+  }
+
+  return true;
 }
 
 /**
@@ -93,27 +120,50 @@ export function getBlockStringIndentation(value: string): number {
  */
 export function printBlockString(
   value: string,
-  preferMultipleLines: boolean = false,
+  options?: { minimize?: boolean },
 ): string {
-  const isSingleLine = !value.includes('\n');
-  const hasLeadingSpace = value.startsWith(' ') || value.startsWith('\t');
-  const hasTrailingQuote = value.endsWith('"');
+  const escapedValue = value.replace(/"""/g, '\\"""');
+
+  // Expand a block string's raw value into independent lines.
+  const lines = escapedValue.split(/\r\n|[\n\r]/g);
+  const isSingleLine = lines.length === 1;
+
+  // If common indentation is found we can fix some of those cases by adding leading new line
+  const forceLeadingNewLine =
+    lines.length > 1 &&
+    lines
+      .slice(1)
+      .every((line) => line.length === 0 || isWhiteSpace(line.charCodeAt(0)));
+
+  // Trailing triple quotes just looks confusing but doesn't force trailing new line
+  const hasTrailingTripleQuotes = escapedValue.endsWith('\\"""');
+
+  // Trailing quote (single or double) or slash forces trailing new line
+  const hasTrailingQuote = value.endsWith('"') && !hasTrailingTripleQuotes;
   const hasTrailingSlash = value.endsWith('\\');
+  const forceTrailingNewline = hasTrailingQuote || hasTrailingSlash;
+
   const printAsMultipleLines =
-    !isSingleLine ||
-    hasTrailingQuote ||
-    hasTrailingSlash ||
-    preferMultipleLines;
+    !options?.minimize &&
+    // add leading and trailing new lines only if it improves readability
+    (!isSingleLine ||
+      value.length > 70 ||
+      forceTrailingNewline ||
+      forceLeadingNewLine ||
+      hasTrailingTripleQuotes);
 
   let result = '';
+
   // Format a multi-line block quote to account for leading space.
-  if (printAsMultipleLines && !(isSingleLine && hasLeadingSpace)) {
+  const skipLeadingNewLine = isSingleLine && isWhiteSpace(value.charCodeAt(0));
+  if ((printAsMultipleLines && !skipLeadingNewLine) || forceLeadingNewLine) {
     result += '\n';
   }
-  result += value;
-  if (printAsMultipleLines) {
+
+  result += escapedValue;
+  if (printAsMultipleLines || forceTrailingNewline) {
     result += '\n';
   }
 
-  return '"""' + result.replace(/"""/g, '\\"""') + '"""';
+  return '"""' + result + '"""';
 }
diff --git a/src/language/characterClasses.ts b/src/language/characterClasses.ts
index 4489f9ed..c1182d10 100644
--- a/src/language/characterClasses.ts
+++ b/src/language/characterClasses.ts
@@ -1,3 +1,15 @@
+/**
+ * ```
+ * WhiteSpace ::
+ *   - "Horizontal Tab (U+0009)"
+ *   - "Space (U+0020)"
+ * ```
+ * @internal
+ */
+export function isWhiteSpace(code: number): boolean {
+  return code === 0x0009 || code === 0x0020;
+}
+
 /**
  * ```
  * Digit :: one of
diff --git a/src/language/lexer.ts b/src/language/lexer.ts
index 2053dd04..a28c1084 100644
--- a/src/language/lexer.ts
+++ b/src/language/lexer.ts
@@ -3,7 +3,7 @@ import { syntaxError } from '../error/syntaxError';
 import type { Source } from './source';
 import { Token } from './ast';
 import { TokenKind } from './tokenKind';
-import { dedentBlockStringValue } from './blockString';
+import { dedentBlockStringLines } from './blockString';
 import { isDigit, isNameStart, isNameContinue } from './characterClasses';
 
 /**
@@ -739,13 +739,13 @@ function readEscapedCharacter(lexer: Lexer, position: number): EscapeSequence {
 function readBlockString(lexer: Lexer, start: number): Token {
   const body = lexer.source.body;
   const bodyLength = body.length;
-  const startLine = lexer.line;
-  const startColumn = 1 + start - lexer.lineStart;
+  let lineStart = lexer.lineStart;
 
   let position = start + 3;
   let chunkStart = position;
-  let rawValue = '';
+  let currentLine = '';
 
+  const blockLines = [];
   while (position < bodyLength) {
     const code = body.charCodeAt(position);
 
@@ -755,15 +755,21 @@ function readBlockString(lexer: Lexer, start: number): Token {
       body.charCodeAt(position + 1) === 0x0022 &&
       body.charCodeAt(position + 2) === 0x0022
     ) {
-      rawValue += body.slice(chunkStart, position);
-      return new Token(
+      currentLine += body.slice(chunkStart, position);
+      blockLines.push(currentLine);
+
+      const token = createToken(
+        lexer,
         TokenKind.BLOCK_STRING,
         start,
         position + 3,
-        startLine,
-        startColumn,
-        dedentBlockStringValue(rawValue),
+        // Return a string of the lines joined with U+000A.
+        dedentBlockStringLines(blockLines).join('\n'),
       );
+
+      lexer.line += blockLines.length - 1;
+      lexer.lineStart = lineStart;
+      return token;
     }
 
     // Escaped Triple-Quote (\""")
@@ -773,21 +779,26 @@ function readBlockString(lexer: Lexer, start: number): Token {
       body.charCodeAt(position + 2) === 0x0022 &&
       body.charCodeAt(position + 3) === 0x0022
     ) {
-      rawValue += body.slice(chunkStart, position) + '"""';
+      currentLine += body.slice(chunkStart, position);
+      chunkStart = position + 1; // skip only slash
       position += 4;
-      chunkStart = position;
       continue;
     }
 
     // LineTerminator
     if (code === 0x000a || code === 0x000d) {
+      currentLine += body.slice(chunkStart, position);
+      blockLines.push(currentLine);
+
       if (code === 0x000d && body.charCodeAt(position + 1) === 0x000a) {
         position += 2;
       } else {
         ++position;
       }
-      ++lexer.line;
-      lexer.lineStart = position;
+
+      currentLine = '';
+      chunkStart = position;
+      lineStart = position;
       continue;
     }
 
diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts
index 16b93e7b..c2eecbd8 100644
--- a/src/utilities/printSchema.ts
+++ b/src/utilities/printSchema.ts
@@ -4,7 +4,7 @@ import type { Maybe } from '../jsutils/Maybe';
 
 import { Kind } from '../language/kinds';
 import { print } from '../language/printer';
-import { printBlockString } from '../language/blockString';
+import { isPrintableAsBlockString } from '../language/blockString';
 
 import type { GraphQLSchema } from '../type/schema';
 import type { GraphQLDirective } from '../type/directives';
@@ -314,8 +314,12 @@ function printDescription(
     return '';
   }
 
-  const preferMultipleLines = description.length > 70;
-  const blockString = printBlockString(description, preferMultipleLines);
+  const blockString = print({
+    kind: Kind.STRING,
+    value: description,
+    block: isPrintableAsBlockString(description),
+  });
+
   const prefix =
     indentation && !firstInBlock ? '\n' + indentation : indentation;
 
diff --git a/src/utilities/stripIgnoredCharacters.ts b/src/utilities/stripIgnoredCharacters.ts
index 9ced5b71..41d5c2a0 100644
--- a/src/utilities/stripIgnoredCharacters.ts
+++ b/src/utilities/stripIgnoredCharacters.ts
@@ -1,10 +1,7 @@
 import { Source, isSource } from '../language/source';
 import { TokenKind } from '../language/tokenKind';
 import { Lexer, isPunctuatorTokenKind } from '../language/lexer';
-import {
-  dedentBlockStringValue,
-  getBlockStringIndentation,
-} from '../language/blockString';
+import { printBlockString } from '../language/blockString';
 
 /**
  * Strips characters that are not significant to the validity or execution
@@ -92,7 +89,7 @@ export function stripIgnoredCharacters(source: string | Source): string {
 
     const tokenBody = body.slice(currentToken.start, currentToken.end);
     if (tokenKind === TokenKind.BLOCK_STRING) {
-      strippedBody += dedentBlockString(tokenBody);
+      strippedBody += printBlockString(currentToken.value, { minimize: true });
     } else {
       strippedBody += tokenBody;
     }
@@ -102,20 +99,3 @@ export function stripIgnoredCharacters(source: string | Source): string {
 
   return strippedBody;
 }
-
-function dedentBlockString(blockStr: string): string {
-  // skip leading and trailing triple quotations
-  const rawStr = blockStr.slice(3, -3);
-  let body = dedentBlockStringValue(rawStr);
-
-  if (getBlockStringIndentation(body) > 0) {
-    body = '\n' + body;
-  }
-
-  const hasTrailingQuote = body.endsWith('"') && !body.endsWith('\\"""');
-  if (hasTrailingQuote || body.endsWith('\\')) {
-    body += '\n';
-  }
-
-  return '"""' + body + '"""';
-}


Test patch:
diff --git a/src/language/__tests__/blockString-fuzz.ts b/src/language/__tests__/blockString-fuzz.ts
index a8d0fd89..0e1f5ec1 100644
--- a/src/language/__tests__/blockString-fuzz.ts
+++ b/src/language/__tests__/blockString-fuzz.ts
@@ -8,7 +8,7 @@ import { invariant } from '../../jsutils/invariant';
 
 import { Lexer } from '../lexer';
 import { Source } from '../source';
-import { printBlockString } from '../blockString';
+import { printBlockString, isPrintableAsBlockString } from '../blockString';
 
 function lexValue(str: string): string {
   const lexer = new Lexer(new Source(str));
@@ -19,6 +19,34 @@ function lexValue(str: string): string {
   return value;
 }
 
+function testPrintableBlockString(
+  testValue: string,
+  options?: { minimize: boolean },
+): void {
+  const blockString = printBlockString(testValue, options);
+  const printedValue = lexValue(blockString);
+  invariant(
+    testValue === printedValue,
+    dedent`
+      Expected lexValue(${inspectStr(blockString)})
+         to equal ${inspectStr(testValue)}
+         but got  ${inspectStr(printedValue)}
+     `,
+  );
+}
+
+function testNonPrintableBlockString(testValue: string): void {
+  const blockString = printBlockString(testValue);
+  const printedValue = lexValue(blockString);
+  invariant(
+    testValue !== printedValue,
+    dedent`
+      Expected lexValue(${inspectStr(blockString)})
+        to not equal ${inspectStr(testValue)}
+    `,
+  );
+}
+
 describe('printBlockString', () => {
   it('correctly print random strings', () => {
     // Testing with length >7 is taking exponentially more time. However it is
@@ -27,39 +55,13 @@ describe('printBlockString', () => {
       allowedChars: ['\n', '\t', ' ', '"', 'a', '\\'],
       maxLength: 7,
     })) {
-      const testStr = '"""' + fuzzStr + '"""';
-
-      let testValue;
-      try {
-        testValue = lexValue(testStr);
-      } catch (e) {
-        continue; // skip invalid values
+      if (!isPrintableAsBlockString(fuzzStr)) {
+        testNonPrintableBlockString(fuzzStr);
+        continue;
       }
-      invariant(typeof testValue === 'string');
-
-      const printedValue = lexValue(printBlockString(testValue));
-
-      invariant(
-        testValue === printedValue,
-        dedent`
-          Expected lexValue(printBlockString(${inspectStr(testValue)}))
-            to equal ${inspectStr(testValue)}
-            but got  ${inspectStr(printedValue)}
-        `,
-      );
-
-      const printedMultilineString = lexValue(
-        printBlockString(testValue, true),
-      );
 
-      invariant(
-        testValue === printedMultilineString,
-        dedent`
-          Expected lexValue(printBlockString(${inspectStr(testValue)}, true))
-            to equal ${inspectStr(testValue)}
-            but got  ${inspectStr(printedMultilineString)}
-        `,
-      );
+      testPrintableBlockString(fuzzStr);
+      testPrintableBlockString(fuzzStr, { minimize: true });
     }
   }).timeout(20000);
 });
diff --git a/src/language/__tests__/blockString-test.ts b/src/language/__tests__/blockString-test.ts
index 9f0022fe..f4a87039 100644
--- a/src/language/__tests__/blockString-test.ts
+++ b/src/language/__tests__/blockString-test.ts
@@ -2,8 +2,8 @@ import { expect } from 'chai';
 import { describe, it } from 'mocha';
 
 import {
-  dedentBlockStringValue,
-  getBlockStringIndentation,
+  isPrintableAsBlockString,
+  dedentBlockStringLines,
   printBlockString,
 } from '../blockString';
 
@@ -11,23 +11,57 @@ function joinLines(...args: ReadonlyArray<string>) {
   return args.join('\n');
 }
 
-describe('dedentBlockStringValue', () => {
+describe('dedentBlockStringLines', () => {
+  function expectDedent(lines: ReadonlyArray<string>) {
+    return expect(dedentBlockStringLines(lines));
+  }
+
+  it('handles empty string', () => {
+    expectDedent(['']).to.deep.equal([]);
+  });
+
+  it('do not dedent first line', () => {
+    expectDedent(['  a']).to.deep.equal(['  a']);
+    expectDedent([' a', '  b']).to.deep.equal([' a', 'b']);
+  });
+
+  it('removes minimal indentation length', () => {
+    expectDedent(['', ' a', '  b']).to.deep.equal(['a', ' b']);
+    expectDedent(['', '  a', ' b']).to.deep.equal([' a', 'b']);
+    expectDedent(['', '  a', ' b', 'c']).to.deep.equal(['  a', ' b', 'c']);
+  });
+
+  it('dedent both tab and space as single character', () => {
+    expectDedent(['', '\ta', '          b']).to.deep.equal(['a', '         b']);
+    expectDedent(['', '\t a', '          b']).to.deep.equal(['a', '        b']);
+    expectDedent(['', ' \t a', '          b']).to.deep.equal(['a', '       b']);
+  });
+
+  it('dedent do not take empty lines into account', () => {
+    expectDedent(['a', '', ' b']).to.deep.equal(['a', '', 'b']);
+    expectDedent(['a', ' ', '  b']).to.deep.equal(['a', '', 'b']);
+  });
+
   it('removes uniform indentation from a string', () => {
-    const rawValue = joinLines(
+    const lines = [
       '',
       '    Hello,',
       '      World!',
       '',
       '    Yours,',
       '      GraphQL.',
-    );
-    expect(dedentBlockStringValue(rawValue)).to.equal(
-      joinLines('Hello,', '  World!', '', 'Yours,', '  GraphQL.'),
-    );
+    ];
+    expectDedent(lines).to.deep.equal([
+      'Hello,',
+      '  World!',
+      '',
+      'Yours,',
+      '  GraphQL.',
+    ]);
   });
 
   it('removes empty leading and trailing lines', () => {
-    const rawValue = joinLines(
+    const lines = [
       '',
       '',
       '    Hello,',
@@ -37,14 +71,18 @@ describe('dedentBlockStringValue', () => {
       '      GraphQL.',
       '',
       '',
-    );
-    expect(dedentBlockStringValue(rawValue)).to.equal(
-      joinLines('Hello,', '  World!', '', 'Yours,', '  GraphQL.'),
-    );
+    ];
+    expectDedent(lines).to.deep.equal([
+      'Hello,',
+      '  World!',
+      '',
+      'Yours,',
+      '  GraphQL.',
+    ]);
   });
 
   it('removes blank leading and trailing lines', () => {
-    const rawValue = joinLines(
+    const lines = [
       '  ',
       '        ',
       '    Hello,',
@@ -54,27 +92,35 @@ describe('dedentBlockStringValue', () => {
       '      GraphQL.',
       '        ',
       '  ',
-    );
-    expect(dedentBlockStringValue(rawValue)).to.equal(
-      joinLines('Hello,', '  World!', '', 'Yours,', '  GraphQL.'),
-    );
+    ];
+    expectDedent(lines).to.deep.equal([
+      'Hello,',
+      '  World!',
+      '',
+      'Yours,',
+      '  GraphQL.',
+    ]);
   });
 
   it('retains indentation from first line', () => {
-    const rawValue = joinLines(
+    const lines = [
       '    Hello,',
       '      World!',
       '',
       '    Yours,',
       '      GraphQL.',
-    );
-    expect(dedentBlockStringValue(rawValue)).to.equal(
-      joinLines('    Hello,', '  World!', '', 'Yours,', '  GraphQL.'),
-    );
+    ];
+    expectDedent(lines).to.deep.equal([
+      '    Hello,',
+      '  World!',
+      '',
+      'Yours,',
+      '  GraphQL.',
+    ]);
   });
 
   it('does not alter trailing spaces', () => {
-    const rawValue = joinLines(
+    const lines = [
       '               ',
       '    Hello,     ',
       '      World!   ',
@@ -82,86 +128,142 @@ describe('dedentBlockStringValue', () => {
       '    Yours,     ',
       '      GraphQL. ',
       '               ',
-    );
-    expect(dedentBlockStringValue(rawValue)).to.equal(
-      joinLines(
-        'Hello,     ',
-        '  World!   ',
-        '           ',
-        'Yours,     ',
-        '  GraphQL. ',
-      ),
-    );
+    ];
+    expectDedent(lines).to.deep.equal([
+      'Hello,     ',
+      '  World!   ',
+      '           ',
+      'Yours,     ',
+      '  GraphQL. ',
+    ]);
   });
 });
 
-describe('getBlockStringIndentation', () => {
-  it('returns zero for an empty string', () => {
-    expect(getBlockStringIndentation('')).to.equal(0);
+describe('isPrintableAsBlockString', () => {
+  function expectPrintable(str: string) {
+    return expect(isPrintableAsBlockString(str)).to.equal(true);
+  }
+
+  function expectNonPrintable(str: string) {
+    return expect(isPrintableAsBlockString(str)).to.equal(false);
+  }
+
+  it('accepts valid strings', () => {
+    expectPrintable('');
+    expectPrintable(' a');
+    expectPrintable('\t"\n"');
+    expectNonPrintable('\t"\n \n\t"');
+  });
+
+  it('rejects strings with only whitespace', () => {
+    expectNonPrintable(' ');
+    expectNonPrintable('\t');
+    expectNonPrintable('\t ');
+    expectNonPrintable(' \t');
   });
 
-  it('do not take first line into account', () => {
-    expect(getBlockStringIndentation('  a')).to.equal(0);
-    expect(getBlockStringIndentation(' a\n  b')).to.equal(2);
+  it('rejects strings with non-printable character', () => {
+    expectNonPrintable('\x00');
+    expectNonPrintable('a\x00b');
   });
 
-  it('returns minimal indentation length', () => {
-    expect(getBlockStringIndentation('\n a\n  b')).to.equal(1);
-    expect(getBlockStringIndentation('\n  a\n b')).to.equal(1);
-    expect(getBlockStringIndentation('\n  a\n b\nc')).to.equal(0);
+  it('rejects strings with non-printable character', () => {
+    expectNonPrintable('\x00');
+    expectNonPrintable('a\x00b');
   });
 
-  it('count both tab and space as single character', () => {
-    expect(getBlockStringIndentation('\n\ta\n          b')).to.equal(1);
-    expect(getBlockStringIndentation('\n\t a\n          b')).to.equal(2);
-    expect(getBlockStringIndentation('\n \t a\n          b')).to.equal(3);
+  it('rejects strings with only empty lines', () => {
+    expectNonPrintable('\n');
+    expectNonPrintable('\n\n');
+    expectNonPrintable('\n\n\n');
+    expectNonPrintable(' \n  \n');
+    expectNonPrintable('\t\n\t\t\n');
   });
 
-  it('do not take empty lines into account', () => {
-    expect(getBlockStringIndentation('a\n ')).to.equal(0);
-    expect(getBlockStringIndentation('a\n\t')).to.equal(0);
-    expect(getBlockStringIndentation('a\n\n b')).to.equal(1);
-    expect(getBlockStringIndentation('a\n \n  b')).to.equal(2);
+  it('rejects strings with carriage return', () => {
+    expectNonPrintable('\r');
+    expectNonPrintable('\n\r');
+    expectNonPrintable('\r\n');
+    expectNonPrintable('a\rb');
+  });
+
+  it('rejects strings with leading empty lines', () => {
+    expectNonPrintable('\na');
+    expectNonPrintable(' \na');
+    expectNonPrintable('\t\na');
+    expectNonPrintable('\n\na');
+  });
+
+  it('rejects strings with leading empty lines', () => {
+    expectNonPrintable('a\n');
+    expectNonPrintable('a\n ');
+    expectNonPrintable('a\n\t');
+    expectNonPrintable('a\n\n');
   });
 });
 
 describe('printBlockString', () => {
+  function expectBlockString(str: string) {
+    return {
+      toEqual(expected: string | { readable: string; minimize: string }) {
+        const { readable, minimize } =
+          typeof expected === 'string'
+            ? { readable: expected, minimize: expected }
+            : expected;
+
+        expect(printBlockString(str)).to.equal(readable);
+        expect(printBlockString(str, { minimize: true })).to.equal(minimize);
+      },
+    };
+  }
+
   it('do not escape characters', () => {
     const str = '" \\ / \b \f \n \r \t';
-    expect(printBlockString(str)).to.equal('"""\n' + str + '\n"""');
+    expectBlockString(str).toEqual({
+      readable: '"""\n' + str + '\n"""',
+      minimize: '"""\n' + str + '"""',
+    });
   });
 
   it('by default print block strings as single line', () => {
     const str = 'one liner';
-    expect(printBlockString(str)).to.equal('"""one liner"""');
-    expect(printBlockString(str, true)).to.equal('"""\none liner\n"""');
+    expectBlockString(str).toEqual('"""one liner"""');
+  });
+
+  it('by default print block strings ending with triple quotation as multi-line', () => {
+    const str = 'triple quotation """';
+    expectBlockString(str).toEqual({
+      readable: '"""\ntriple quotation \\"""\n"""',
+      minimize: '"""triple quotation \\""""""',
+    });
   });
 
   it('correctly prints single-line with leading space', () => {
     const str = '    space-led string';
-    expect(printBlockString(str)).to.equal('"""    space-led string"""');
-    expect(printBlockString(str, true)).to.equal(
-      '"""    space-led string\n"""',
-    );
+    expectBlockString(str).toEqual('"""    space-led string"""');
   });
 
-  it('correctly prints single-line with leading space and quotation', () => {
+  it('correctly prints single-line with leading space and trailing quotation', () => {
     const str = '    space-led value "quoted string"';
-
-    expect(printBlockString(str)).to.equal(
-      '"""    space-led value "quoted string"\n"""',
-    );
-
-    expect(printBlockString(str, true)).to.equal(
+    expectBlockString(str).toEqual(
       '"""    space-led value "quoted string"\n"""',
     );
   });
 
   it('correctly prints single-line with trailing backslash', () => {
     const str = 'backslash \\';
+    expectBlockString(str).toEqual({
+      readable: '"""\nbackslash \\\n"""',
+      minimize: '"""backslash \\\n"""',
+    });
+  });
 
-    expect(printBlockString(str)).to.equal('"""\nbackslash \\\n"""');
-    expect(printBlockString(str, true)).to.equal('"""\nbackslash \\\n"""');
+  it('correctly prints multi-line with internal indent', () => {
+    const str = 'no indent\n with indent';
+    expectBlockString(str).toEqual({
+      readable: '"""\nno indent\n with indent\n"""',
+      minimize: '"""\nno indent\n with indent"""',
+    });
   });
 
   it('correctly prints string with a first line indentation', () => {
@@ -172,8 +274,8 @@ describe('printBlockString', () => {
       '     string',
     );
 
-    expect(printBlockString(str)).to.equal(
-      joinLines(
+    expectBlockString(str).toEqual({
+      readable: joinLines(
         '"""',
         '    first  ',
         '  line     ',
@@ -181,6 +283,12 @@ describe('printBlockString', () => {
         '     string',
         '"""',
       ),
-    );
+      minimize: joinLines(
+        '"""    first  ',
+        '  line     ',
+        'indentation',
+        '     string"""',
+      ),
+    });
   });
 });
diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts
index c9c16f0d..7cef1f59 100644
--- a/src/utilities/__tests__/printSchema-test.ts
+++ b/src/utilities/__tests__/printSchema-test.ts
@@ -593,6 +593,20 @@ describe('Type System Printer', () => {
     `);
   });
 
+  it('Prints an description with only whitespace', () => {
+    const schema = buildSingleFieldSchema({
+      type: GraphQLString,
+      description: ' ',
+    });
+
+    expectPrintedSchema(schema).to.equal(dedent`
+      type Query {
+        " "
+        singleField: String
+      }
+    `);
+  });
+
   it('One-line prints a short description', () => {
     const schema = buildSingleFieldSchema({
       type: GraphQLString,