Skip to content

Commit 3fa0624

Browse files
committed
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.
1 parent f2d2d24 commit 3fa0624

File tree

9 files changed

+212
-11
lines changed

9 files changed

+212
-11
lines changed

src/language/__tests__/kitchen-sink.graphql

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) {
4848
}
4949

5050
fragment frag on Friend {
51-
foo(size: $size, bar: $b, obj: {key: "value"})
51+
foo(size: $size, bar: $b, obj: {key: "value", multiLine: """string"""})
5252
}
5353

5454
{

src/language/__tests__/lexer-test.js

+100
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,106 @@ describe('Lexer', () => {
258258
);
259259
});
260260

261+
it('lexes multi-line strings', () => {
262+
263+
expect(
264+
lexOne('"""simple"""')
265+
).to.containSubset({
266+
kind: TokenKind.MULTI_LINE_STRING,
267+
start: 0,
268+
end: 12,
269+
value: 'simple'
270+
});
271+
272+
expect(
273+
lexOne('""" white space """')
274+
).to.containSubset({
275+
kind: TokenKind.MULTI_LINE_STRING,
276+
start: 0,
277+
end: 19,
278+
value: ' white space '
279+
});
280+
281+
expect(
282+
lexOne('"""contains " quote"""')
283+
).to.containSubset({
284+
kind: TokenKind.MULTI_LINE_STRING,
285+
start: 0,
286+
end: 22,
287+
value: 'contains " quote'
288+
});
289+
290+
expect(
291+
lexOne('"""contains \\""" triplequote"""')
292+
).to.containSubset({
293+
kind: TokenKind.MULTI_LINE_STRING,
294+
start: 0,
295+
end: 31,
296+
value: 'contains """ triplequote'
297+
});
298+
299+
expect(
300+
lexOne('"""multi\nline"""')
301+
).to.containSubset({
302+
kind: TokenKind.MULTI_LINE_STRING,
303+
start: 0,
304+
end: 16,
305+
value: 'multi\nline'
306+
});
307+
308+
expect(
309+
lexOne('"""multi\rline"""')
310+
).to.containSubset({
311+
kind: TokenKind.MULTI_LINE_STRING,
312+
start: 0,
313+
end: 16,
314+
value: 'multi\rline'
315+
});
316+
317+
expect(
318+
lexOne('"""unescaped \\n\\r\\b\\t\\f\\u1234"""')
319+
).to.containSubset({
320+
kind: TokenKind.MULTI_LINE_STRING,
321+
start: 0,
322+
end: 32,
323+
value: 'unescaped \\n\\r\\b\\t\\f\\u1234'
324+
});
325+
326+
expect(
327+
lexOne('"""slashes \\\\ \\/"""')
328+
).to.containSubset({
329+
kind: TokenKind.MULTI_LINE_STRING,
330+
start: 0,
331+
end: 19,
332+
value: 'slashes \\\\ \\/'
333+
});
334+
335+
});
336+
337+
it('lex reports useful multi-line string errors', () => {
338+
339+
expect(
340+
() => lexOne('"""')
341+
).to.throw('Syntax Error GraphQL request (1:4) Unterminated string.');
342+
343+
expect(
344+
() => lexOne('"""no end quote')
345+
).to.throw('Syntax Error GraphQL request (1:16) Unterminated string.');
346+
347+
expect(
348+
() => lexOne('"""contains unescaped \u0007 control char"""')
349+
).to.throw(
350+
'Syntax Error GraphQL request (1:23) Invalid character within String: "\\u0007".'
351+
);
352+
353+
expect(
354+
() => lexOne('"""null-byte is not \u0000 end of file"""')
355+
).to.throw(
356+
'Syntax Error GraphQL request (1:21) Invalid character within String: "\\u0000".'
357+
);
358+
359+
});
360+
261361
it('lexes numbers', () => {
262362

263363
expect(

src/language/__tests__/parser-test.js

+16
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,22 @@ describe('Parser', () => {
328328
});
329329
});
330330

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

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

src/language/__tests__/printer-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('Printer', () => {
129129
}
130130
131131
fragment frag on Friend {
132-
foo(size: $size, bar: $b, obj: {key: "value"})
132+
foo(size: $size, bar: $b, obj: {key: "value", multiLine: """string"""})
133133
}
134134
135135
{

src/language/__tests__/visitor-test.js

+6
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,12 @@ describe('Visitor', () => {
592592
[ 'enter', 'StringValue', 'value', 'ObjectField' ],
593593
[ 'leave', 'StringValue', 'value', 'ObjectField' ],
594594
[ 'leave', 'ObjectField', 0, undefined ],
595+
[ 'enter', 'ObjectField', 1, undefined ],
596+
[ 'enter', 'Name', 'name', 'ObjectField' ],
597+
[ 'leave', 'Name', 'name', 'ObjectField' ],
598+
[ 'enter', 'StringValue', 'value', 'ObjectField' ],
599+
[ 'leave', 'StringValue', 'value', 'ObjectField' ],
600+
[ 'leave', 'ObjectField', 1, undefined ],
595601
[ 'leave', 'ObjectValue', 'value', 'Argument' ],
596602
[ 'leave', 'Argument', 2, undefined ],
597603
[ 'leave', 'Field', 0, undefined ],

src/language/ast.js

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type TokenKind = '<SOF>'
6767
| 'Int'
6868
| 'Float'
6969
| 'String'
70+
| 'MultiLineString'
7071
| 'Comment';
7172

7273
/**
@@ -289,6 +290,7 @@ export type StringValueNode = {
289290
kind: 'StringValue';
290291
loc?: Location;
291292
value: string;
293+
multiLine?: boolean;
292294
};
293295

294296
export type BooleanValueNode = {

src/language/lexer.js

+80-8
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const NAME = 'Name';
101101
const INT = 'Int';
102102
const FLOAT = 'Float';
103103
const STRING = 'String';
104+
const MULTI_LINE_STRING = 'MultiLineString';
104105
const COMMENT = 'Comment';
105106

106107
/**
@@ -127,6 +128,7 @@ export const TokenKind = {
127128
INT,
128129
FLOAT,
129130
STRING,
131+
MULTI_LINE_STRING,
130132
COMMENT
131133
};
132134

@@ -270,7 +272,12 @@ function readToken(lexer: Lexer<*>, prev: Token): Token {
270272
case 53: case 54: case 55: case 56: case 57:
271273
return readNumber(source, position, code, line, col, prev);
272274
// "
273-
case 34: return readString(source, position, line, col, prev);
275+
case 34:
276+
if (charCodeAt.call(body, position + 1) === 34 &&
277+
charCodeAt.call(body, position + 2) === 34) {
278+
return readMultiLineString(source, position, line, col, prev);
279+
}
280+
return readString(source, position, line, col, prev);
274281
}
275282

276283
throw syntaxError(
@@ -453,10 +460,14 @@ function readString(source, start, line, col, prev): Token {
453460
position < body.length &&
454461
(code = charCodeAt.call(body, position)) !== null &&
455462
// not LineTerminator
456-
code !== 0x000A && code !== 0x000D &&
457-
// not Quote (")
458-
code !== 34
463+
code !== 0x000A && code !== 0x000D
459464
) {
465+
// Closing Quote (")
466+
if (code === 34) {
467+
value += slice.call(body, chunkStart, position);
468+
return new Tok(STRING, start, position + 1, line, col, prev, value);
469+
}
470+
460471
// SourceCharacter
461472
if (code < 0x0020 && code !== 0x0009) {
462473
throw syntaxError(
@@ -509,12 +520,73 @@ function readString(source, start, line, col, prev): Token {
509520
}
510521
}
511522

512-
if (code !== 34) { // quote (")
513-
throw syntaxError(source, position, 'Unterminated string.');
523+
throw syntaxError(source, position, 'Unterminated string.');
524+
}
525+
526+
/**
527+
* Reads a multi-line string token from the source file.
528+
*
529+
* """("?"?(\\"""|\\(?!=""")|[^"\\]))*"""
530+
*/
531+
function readMultiLineString(source, start, line, col, prev): Token {
532+
const body = source.body;
533+
let position = start + 3;
534+
let chunkStart = position;
535+
let code = 0;
536+
let value = '';
537+
538+
while (
539+
position < body.length &&
540+
(code = charCodeAt.call(body, position)) !== null
541+
) {
542+
// Closing Triple-Quote (""")
543+
if (
544+
code === 34 &&
545+
charCodeAt.call(body, position + 1) === 34 &&
546+
charCodeAt.call(body, position + 2) === 34
547+
) {
548+
value += slice.call(body, chunkStart, position);
549+
return new Tok(
550+
MULTI_LINE_STRING,
551+
start,
552+
position + 3,
553+
line,
554+
col,
555+
prev,
556+
value
557+
);
558+
}
559+
560+
// SourceCharacter
561+
if (
562+
code < 0x0020 &&
563+
code !== 0x0009 &&
564+
code !== 0x000A &&
565+
code !== 0x000D
566+
) {
567+
throw syntaxError(
568+
source,
569+
position,
570+
`Invalid character within String: ${printCharCode(code)}.`
571+
);
572+
}
573+
574+
// Escape Triple-Quote (\""")
575+
if (
576+
code === 92 &&
577+
charCodeAt.call(body, position + 1) === 34 &&
578+
charCodeAt.call(body, position + 2) === 34 &&
579+
charCodeAt.call(body, position + 3) === 34
580+
) {
581+
value += slice.call(body, chunkStart, position) + '"""';
582+
position += 4;
583+
chunkStart = position;
584+
} else {
585+
++position;
586+
}
514587
}
515588

516-
value += slice.call(body, chunkStart, position);
517-
return new Tok(STRING, start, position + 1, line, col, prev, value);
589+
throw syntaxError(source, position, 'Unterminated string.');
518590
}
519591

520592
/**

src/language/parser.js

+2
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,12 @@ function parseValueLiteral(lexer: Lexer<*>, isConst: boolean): ValueNode {
544544
loc: loc(lexer, token)
545545
};
546546
case TokenKind.STRING:
547+
case TokenKind.MULTI_LINE_STRING:
547548
lexer.advance();
548549
return {
549550
kind: (STRING: 'StringValue'),
550551
value: ((token.value: any): string),
552+
multiLine: token.kind === TokenKind.MULTI_LINE_STRING,
551553
loc: loc(lexer, token)
552554
};
553555
case TokenKind.NAME:

src/language/printer.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ const printDocASTReducer = {
7474

7575
IntValue: ({ value }) => value,
7676
FloatValue: ({ value }) => value,
77-
StringValue: ({ value }) => JSON.stringify(value),
77+
StringValue: ({ value, multiLine }) =>
78+
multiLine ?
79+
`"""${value.replace(/"""/g, '\\"""')}"""` :
80+
JSON.stringify(value),
7881
BooleanValue: ({ value }) => JSON.stringify(value),
7982
NullValue: () => 'null',
8083
EnumValue: ({ value }) => value,

0 commit comments

Comments
 (0)