diff --git a/src/language/__tests__/blockString-fuzz.ts b/src/language/__tests__/blockString-fuzz.ts index ac40efc7e8..0e1f5ec1f6 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)); @@ -35,6 +35,18 @@ function testPrintableBlockString( ); } +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 @@ -43,18 +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'); - testPrintableBlockString(testValue); - testPrintableBlockString(testValue, { minimize: true }); + 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 2bab7e83d8..f4a870398a 100644 --- a/src/language/__tests__/blockString-test.ts +++ b/src/language/__tests__/blockString-test.ts @@ -1,7 +1,11 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { dedentBlockStringLines, printBlockString } from '../blockString'; +import { + isPrintableAsBlockString, + dedentBlockStringLines, + printBlockString, +} from '../blockString'; function joinLines(...args: ReadonlyArray) { return args.join('\n'); @@ -135,6 +139,69 @@ describe('dedentBlockStringLines', () => { }); }); +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('rejects strings with non-printable character', () => { + expectNonPrintable('\x00'); + expectNonPrintable('a\x00b'); + }); + + it('rejects strings with non-printable character', () => { + expectNonPrintable('\x00'); + expectNonPrintable('a\x00b'); + }); + + 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('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 { diff --git a/src/language/blockString.ts b/src/language/blockString.ts index 4b177bc45a..1c200c183a 100644 --- a/src/language/blockString.ts +++ b/src/language/blockString.ts @@ -48,6 +48,69 @@ function leadingWhitespace(str: string): number { return i; } +/** + * @internal + */ +export function isPrintableAsBlockString(value: string): boolean { + if (value === '') { + return true; // empty string is printable + } + + let isEmptyLine = true; + let hasIndent = false; + let hasCommonIndent = true; + let seenNonEmptyLine = false; + + for (let i = 0; i < value.length; ++i) { + 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 + if (isEmptyLine && !seenNonEmptyLine) { + return false; // Has leading new line + } + seenNonEmptyLine = true; + + isEmptyLine = true; + hasIndent = false; + break; + case 9: // \t + case 32: // + hasIndent ||= isEmptyLine; + break; + default: + hasCommonIndent &&= hasIndent; + isEmptyLine = false; + } + } + + if (isEmptyLine) { + return false; // Has trailing empty lines + } + + if (hasCommonIndent && seenNonEmptyLine) { + return false; // Has internal indent + } + + return true; +} + /** * Print a block string in the indented block form by adding a leading and * trailing blank line. However, if a block string starts with whitespace and is diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index c9c16f0d5c..7cef1f5978 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, diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index c7b113868f..c2eecbd846 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -4,6 +4,7 @@ import type { Maybe } from '../jsutils/Maybe'; import { Kind } from '../language/kinds'; import { print } from '../language/printer'; +import { isPrintableAsBlockString } from '../language/blockString'; import type { GraphQLSchema } from '../type/schema'; import type { GraphQLDirective } from '../type/directives'; @@ -316,7 +317,7 @@ function printDescription( const blockString = print({ kind: Kind.STRING, value: description, - block: true, + block: isPrintableAsBlockString(description), }); const prefix =