Skip to content

Commit aea5964

Browse files
authored
RFC: Block String (#926)
* RFC: Multi-line String This RFC adds a new form of `StringValue`, the multi-line string, similar to that found in Python and Scala. A multi-line string starts and ends with a triple-quote: ``` """This is a triple-quoted string and it can contain multiple lines""" ``` Multi-line strings are useful for typing literal bodies of text where new lines should be interpretted literally. In fact, the only escape sequence used is `\"""` and `\` is otherwise allowed unescaped. This is beneficial when writing documentation within strings which may reference the back-slash often: ``` """ In a multi-line string \n and C:\\ are unescaped. """ ``` The primary value of multi-line strings are to write long-form input directly in query text, in tools like GraphiQL, and as a prerequisite to another pending RFC to allow docstring style documentation in the Schema Definition Language. * Add RemoveIndentation() to the lexer for multi-line strings. * blockStringValue
1 parent fed3ef5 commit aea5964

11 files changed

+408
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { expect } from 'chai';
9+
import { describe, it } from 'mocha';
10+
import blockStringValue from '../blockStringValue';
11+
12+
describe('blockStringValue', () => {
13+
14+
it('removes uniform indentation from a string', () => {
15+
const rawValue = [
16+
'',
17+
' Hello,',
18+
' World!',
19+
'',
20+
' Yours,',
21+
' GraphQL.',
22+
].join('\n');
23+
expect(blockStringValue(rawValue)).to.equal([
24+
'Hello,',
25+
' World!',
26+
'',
27+
'Yours,',
28+
' GraphQL.',
29+
].join('\n'));
30+
});
31+
32+
it('removes empty leading and trailing lines', () => {
33+
const rawValue = [
34+
'',
35+
'',
36+
' Hello,',
37+
' World!',
38+
'',
39+
' Yours,',
40+
' GraphQL.',
41+
'',
42+
'',
43+
].join('\n');
44+
expect(blockStringValue(rawValue)).to.equal([
45+
'Hello,',
46+
' World!',
47+
'',
48+
'Yours,',
49+
' GraphQL.',
50+
].join('\n'));
51+
});
52+
53+
it('removes blank leading and trailing lines', () => {
54+
const rawValue = [
55+
' ',
56+
' ',
57+
' Hello,',
58+
' World!',
59+
'',
60+
' Yours,',
61+
' GraphQL.',
62+
' ',
63+
' ',
64+
].join('\n');
65+
expect(blockStringValue(rawValue)).to.equal([
66+
'Hello,',
67+
' World!',
68+
'',
69+
'Yours,',
70+
' GraphQL.',
71+
].join('\n'));
72+
});
73+
74+
it('retains indentation from first line', () => {
75+
const rawValue = [
76+
' Hello,',
77+
' World!',
78+
'',
79+
' Yours,',
80+
' GraphQL.',
81+
].join('\n');
82+
expect(blockStringValue(rawValue)).to.equal([
83+
' Hello,',
84+
' World!',
85+
'',
86+
'Yours,',
87+
' GraphQL.',
88+
].join('\n'));
89+
});
90+
91+
it('does not alter trailing spaces', () => {
92+
const rawValue = [
93+
' ',
94+
' Hello, ',
95+
' World! ',
96+
' ',
97+
' Yours, ',
98+
' GraphQL. ',
99+
' ',
100+
].join('\n');
101+
expect(blockStringValue(rawValue)).to.equal([
102+
'Hello, ',
103+
' World! ',
104+
' ',
105+
'Yours, ',
106+
' GraphQL. ',
107+
].join('\n'));
108+
});
109+
110+
});

src/language/__tests__/kitchen-sink.graphql

+5-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) {
4646
}
4747

4848
fragment frag on Friend {
49-
foo(size: $size, bar: $b, obj: {key: "value"})
49+
foo(size: $size, bar: $b, obj: {key: "value", block: """
50+
51+
block string uses \"""
52+
53+
"""})
5054
}
5155
5256
{

src/language/__tests__/lexer-test.js

+115
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,121 @@ describe('Lexer', () => {
289289
);
290290
});
291291

292+
it('lexes block strings', () => {
293+
294+
expect(
295+
lexOne('"""simple"""')
296+
).to.containSubset({
297+
kind: TokenKind.BLOCK_STRING,
298+
start: 0,
299+
end: 12,
300+
value: 'simple'
301+
});
302+
303+
expect(
304+
lexOne('" white space "')
305+
).to.containSubset({
306+
kind: TokenKind.STRING,
307+
start: 0,
308+
end: 15,
309+
value: ' white space '
310+
});
311+
312+
expect(
313+
lexOne('"""contains " quote"""')
314+
).to.containSubset({
315+
kind: TokenKind.BLOCK_STRING,
316+
start: 0,
317+
end: 22,
318+
value: 'contains " quote'
319+
});
320+
321+
expect(
322+
lexOne('"""contains \\""" triplequote"""')
323+
).to.containSubset({
324+
kind: TokenKind.BLOCK_STRING,
325+
start: 0,
326+
end: 31,
327+
value: 'contains """ triplequote'
328+
});
329+
330+
expect(
331+
lexOne('"""multi\nline"""')
332+
).to.containSubset({
333+
kind: TokenKind.BLOCK_STRING,
334+
start: 0,
335+
end: 16,
336+
value: 'multi\nline'
337+
});
338+
339+
expect(
340+
lexOne('"""multi\rline\r\nnormalized"""')
341+
).to.containSubset({
342+
kind: TokenKind.BLOCK_STRING,
343+
start: 0,
344+
end: 28,
345+
value: 'multi\nline\nnormalized'
346+
});
347+
348+
expect(
349+
lexOne('"""unescaped \\n\\r\\b\\t\\f\\u1234"""')
350+
).to.containSubset({
351+
kind: TokenKind.BLOCK_STRING,
352+
start: 0,
353+
end: 32,
354+
value: 'unescaped \\n\\r\\b\\t\\f\\u1234'
355+
});
356+
357+
expect(
358+
lexOne('"""slashes \\\\ \\/"""')
359+
).to.containSubset({
360+
kind: TokenKind.BLOCK_STRING,
361+
start: 0,
362+
end: 19,
363+
value: 'slashes \\\\ \\/'
364+
});
365+
366+
expect(
367+
lexOne(`"""
368+
369+
spans
370+
multiple
371+
lines
372+
373+
"""`)
374+
).to.containSubset({
375+
kind: TokenKind.BLOCK_STRING,
376+
start: 0,
377+
end: 68,
378+
value: 'spans\n multiple\n lines'
379+
});
380+
381+
});
382+
383+
it('lex reports useful block string errors', () => {
384+
385+
expect(
386+
() => lexOne('"""')
387+
).to.throw('Syntax Error GraphQL request (1:4) Unterminated string.');
388+
389+
expect(
390+
() => lexOne('"""no end quote')
391+
).to.throw('Syntax Error GraphQL request (1:16) Unterminated string.');
392+
393+
expect(
394+
() => lexOne('"""contains unescaped \u0007 control char"""')
395+
).to.throw(
396+
'Syntax Error GraphQL request (1:23) Invalid character within String: "\\u0007".'
397+
);
398+
399+
expect(
400+
() => lexOne('"""null-byte is not \u0000 end of file"""')
401+
).to.throw(
402+
'Syntax Error GraphQL request (1:21) Invalid character within String: "\\u0000".'
403+
);
404+
405+
});
406+
292407
it('lexes numbers', () => {
293408

294409
expect(

src/language/__tests__/parser-test.js

+16
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,22 @@ describe('Parser', () => {
326326
});
327327
});
328328

329+
it('parses block strings', () => {
330+
expect(parseValue('["""long""" "short"]')).to.containSubset({
331+
kind: Kind.LIST,
332+
loc: { start: 0, end: 20 },
333+
values: [
334+
{ kind: Kind.STRING,
335+
loc: { start: 1, end: 11},
336+
value: 'long',
337+
block: true },
338+
{ kind: Kind.STRING,
339+
loc: { start: 12, end: 19},
340+
value: 'short',
341+
block: false } ]
342+
});
343+
});
344+
329345
});
330346

331347
describe('parseType', () => {

src/language/__tests__/printer-test.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ describe('Printer', () => {
127127
}
128128
129129
fragment frag on Friend {
130-
foo(size: $size, bar: $b, obj: {key: "value"})
130+
foo(size: $size, bar: $b, obj: {key: "value", block: """
131+
block string uses \"""
132+
"""})
131133
}
132134
133135
{

src/language/__tests__/visitor-test.js

+6
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,12 @@ describe('Visitor', () => {
590590
[ 'enter', 'StringValue', 'value', 'ObjectField' ],
591591
[ 'leave', 'StringValue', 'value', 'ObjectField' ],
592592
[ 'leave', 'ObjectField', 0, undefined ],
593+
[ 'enter', 'ObjectField', 1, undefined ],
594+
[ 'enter', 'Name', 'name', 'ObjectField' ],
595+
[ 'leave', 'Name', 'name', 'ObjectField' ],
596+
[ 'enter', 'StringValue', 'value', 'ObjectField' ],
597+
[ 'leave', 'StringValue', 'value', 'ObjectField' ],
598+
[ 'leave', 'ObjectField', 1, undefined ],
593599
[ 'leave', 'ObjectValue', 'value', 'Argument' ],
594600
[ 'leave', 'Argument', 2, undefined ],
595601
[ 'leave', 'Field', 0, undefined ],

src/language/ast.js

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type TokenKind = '<SOF>'
6666
| 'Int'
6767
| 'Float'
6868
| 'String'
69+
| 'BlockString'
6970
| 'Comment';
7071

7172
/**
@@ -288,6 +289,7 @@ export type StringValueNode = {
288289
kind: 'StringValue';
289290
loc?: Location;
290291
value: string;
292+
block?: boolean;
291293
};
292294

293295
export type BooleanValueNode = {

src/language/blockStringValue.js

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
/**
11+
* Produces the value of a block string from its parsed raw value, similar to
12+
* Coffeescript's block string, Python's docstring trim or Ruby's strip_heredoc.
13+
*
14+
* This implements the GraphQL spec's BlockStringValue() static algorithm.
15+
*/
16+
export default function blockStringValue(rawString: string): string {
17+
// Expand a block string's raw value into independent lines.
18+
const lines = rawString.split(/\r\n|[\n\r]/g);
19+
20+
// Remove common indentation from all lines but first.
21+
let commonIndent = null;
22+
for (let i = 1; i < lines.length; i++) {
23+
const line = lines[i];
24+
const indent = leadingWhitespace(line);
25+
if (
26+
indent < line.length &&
27+
(commonIndent === null || indent < commonIndent)
28+
) {
29+
commonIndent = indent;
30+
if (commonIndent === 0) {
31+
break;
32+
}
33+
}
34+
}
35+
36+
if (commonIndent) {
37+
for (let i = 1; i < lines.length; i++) {
38+
lines[i] = lines[i].slice(commonIndent);
39+
}
40+
}
41+
42+
// Remove leading and trailing blank lines.
43+
while (lines.length > 0 && isBlank(lines[0])) {
44+
lines.shift();
45+
}
46+
while (lines.length > 0 && isBlank(lines[lines.length - 1])) {
47+
lines.pop();
48+
}
49+
50+
// Return a string of the lines joined with U+000A.
51+
return lines.join('\n');
52+
}
53+
54+
function leadingWhitespace(str) {
55+
let i = 0;
56+
while (i < str.length && (str[i] === ' ' || str[i] === '\t')) {
57+
i++;
58+
}
59+
return i;
60+
}
61+
62+
function isBlank(str) {
63+
return leadingWhitespace(str) === str.length;
64+
}

0 commit comments

Comments
 (0)