Skip to content

Commit 3645edd

Browse files
committed
Move maxTokens check to lexer
1 parent 123e958 commit 3645edd

File tree

6 files changed

+88
-25
lines changed

6 files changed

+88
-25
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export {
242242

243243
export type {
244244
ParseOptions,
245+
LexerOptions,
245246
SourceLocation,
246247
TokenKindEnum,
247248
KindEnum,

src/language/__tests__/parser-test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ describe('Parser', () => {
106106
expect(() => parse('{ foo(bar: "baz") }', { maxTokens: 7 })).to.throw(
107107
'Syntax Error: Document contains more that 7 tokens. Parsing aborted.',
108108
);
109+
110+
expect(() =>
111+
parse('#\n{\n#\na\n#\na\n#\n}\n#', { maxTokens: 9 }),
112+
).to.not.throw();
113+
expect(() => parse('#\n{\n#\na\n#\na\n#\n}\n#', { maxTokens: 8 })).to.throw(
114+
'Syntax Error: Document contains more that 8 tokens. Parsing aborted.',
115+
);
109116
});
110117

111118
it('parses variable inline values', () => {

src/language/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { TokenKind } from './tokenKind';
1212
export type { TokenKindEnum } from './tokenKind';
1313

1414
export { Lexer } from './lexer';
15+
export type { LexerOptions } from './lexer';
1516

1617
export {
1718
parse,

src/language/lexer.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,31 @@ import { isDigit, isNameContinue, isNameStart } from './characterClasses';
66
import type { Source } from './source';
77
import { TokenKind } from './tokenKind';
88

9+
/**
10+
* Configuration options to control lexer behavior
11+
*/
12+
export interface LexerOptions {
13+
/**
14+
* Parser CPU and memory usage is linear to the number of tokens in a document
15+
* however in extreme cases it becomes quadratic due to memory exhaustion.
16+
* Parsing happens before validation so even invalid queries can burn lots of
17+
* CPU time and memory.
18+
* To prevent this you can set a maximum number of tokens allowed within a document.
19+
*/
20+
maxTokens?: number | undefined;
21+
}
22+
923
/**
1024
* A Lexer interface which provides common properties and methods required for
1125
* lexing GraphQL source.
1226
*
1327
* @internal
1428
*/
1529
export interface LexerInterface {
16-
source: Source;
30+
readonly _options: Readonly<LexerOptions>;
31+
_tokenCounter: number;
32+
readonly source: Source;
33+
tokenCount: number;
1734
lastToken: Token;
1835
token: Token;
1936
line: number;
@@ -31,6 +48,11 @@ export interface LexerInterface {
3148
* whenever called.
3249
*/
3350
export class Lexer implements LexerInterface {
51+
/** @internal */
52+
readonly _options: Readonly<LexerOptions>;
53+
/** @internal */
54+
_tokenCounter: number;
55+
3456
source: Source;
3557

3658
/**
@@ -53,9 +75,11 @@ export class Lexer implements LexerInterface {
5375
*/
5476
lineStart: number;
5577

56-
constructor(source: Source) {
78+
constructor(source: Source, options: LexerOptions = {}) {
5779
const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0);
5880

81+
this._options = options;
82+
this._tokenCounter = 0;
5983
this.source = source;
6084
this.lastToken = startOfFileToken;
6185
this.token = startOfFileToken;
@@ -67,6 +91,10 @@ export class Lexer implements LexerInterface {
6791
return 'Lexer';
6892
}
6993

94+
get tokenCount(): number {
95+
return this._tokenCounter;
96+
}
97+
7098
/**
7199
* Advances the token stream to the next non-ignored token.
72100
*/
@@ -200,8 +228,19 @@ export function createToken(
200228
end: number,
201229
value?: string,
202230
): Token {
231+
const { maxTokens } = lexer._options;
203232
const line = lexer.line;
204233
const col = 1 + start - lexer.lineStart;
234+
if (kind !== TokenKind.EOF) {
235+
++lexer._tokenCounter;
236+
if (maxTokens !== undefined && lexer._tokenCounter > maxTokens) {
237+
throw syntaxError(
238+
lexer.source,
239+
start,
240+
`Document contains more that ${maxTokens} tokens. Parsing aborted.`,
241+
);
242+
}
243+
}
205244
return new Token(kind, start, end, line, col, value);
206245
}
207246

src/language/parser.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,17 @@ export interface ParseOptions {
111111
* ```
112112
*/
113113
allowLegacyFragmentVariables?: boolean;
114+
}
114115

116+
/**
117+
* @internal
118+
*/
119+
export interface ParseOptionsInternal extends ParseOptions {
115120
/**
116121
* You may override the Lexer class used to lex the source; this is used by
117122
* schema coordinates to introduce a lexer with a restricted syntax.
123+
*
124+
* Cannot be set if `maxTokens` is set.
118125
*/
119126
lexer?: LexerInterface | undefined;
120127
}
@@ -204,10 +211,15 @@ export function parseType(
204211
*/
205212
export function parseSchemaCoordinate(
206213
source: string | Source,
214+
options?: ParseOptions | undefined,
207215
): SchemaCoordinateNode {
208216
const sourceObj = isSource(source) ? source : new Source(source);
209-
const lexer = new SchemaCoordinateLexer(sourceObj);
210-
const parser = new Parser(source, { lexer });
217+
const lexer = new SchemaCoordinateLexer(sourceObj, options);
218+
const parser = new Parser(source, {
219+
...options,
220+
maxTokens: undefined, // Handled by SchemaCoordinateLexer
221+
lexer,
222+
});
211223
parser.expectToken(TokenKind.SOF);
212224
const coordinate = parser.parseSchemaCoordinate();
213225
parser.expectToken(TokenKind.EOF);
@@ -226,26 +238,30 @@ export function parseSchemaCoordinate(
226238
* @internal
227239
*/
228240
export class Parser {
229-
protected _options: Omit<ParseOptions, 'lexer'>;
241+
protected _options: ParseOptions;
230242
protected _lexer: LexerInterface;
231-
protected _tokenCounter: number;
232243

233-
constructor(source: string | Source, options: ParseOptions = {}) {
244+
constructor(source: string | Source, options: ParseOptionsInternal = {}) {
234245
const { lexer, ..._options } = options;
235246

236247
if (lexer) {
248+
if (options.maxTokens != null) {
249+
throw new Error(
250+
'Setting maxTokens has no effect when a custom lexer is passed',
251+
);
252+
}
237253
this._lexer = lexer;
238254
} else {
239255
const sourceObj = isSource(source) ? source : new Source(source);
240-
this._lexer = new Lexer(sourceObj);
256+
const { maxTokens } = options;
257+
this._lexer = new Lexer(sourceObj, { maxTokens });
241258
}
242259

243260
this._options = _options;
244-
this._tokenCounter = 0;
245261
}
246262

247263
get tokenCount(): number {
248-
return this._tokenCounter;
264+
return this._lexer.tokenCount;
249265
}
250266

251267
/**
@@ -1690,19 +1706,7 @@ export class Parser {
16901706
}
16911707

16921708
advanceLexer(): void {
1693-
const { maxTokens } = this._options;
1694-
const token = this._lexer.advance();
1695-
1696-
if (token.kind !== TokenKind.EOF) {
1697-
++this._tokenCounter;
1698-
if (maxTokens !== undefined && this._tokenCounter > maxTokens) {
1699-
throw syntaxError(
1700-
this._lexer.source,
1701-
token.start,
1702-
`Document contains more that ${maxTokens} tokens. Parsing aborted.`,
1703-
);
1704-
}
1705-
}
1709+
this._lexer.advance();
17061710
}
17071711
}
17081712

src/language/schemaCoordinateLexer.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { syntaxError } from '../error/syntaxError';
22

33
import { Token } from './ast';
44
import { isNameStart } from './characterClasses';
5-
import type { LexerInterface } from './lexer';
5+
import type { LexerInterface, LexerOptions } from './lexer';
66
import { createToken, printCodePointAt, readName } from './lexer';
77
import type { Source } from './source';
88
import { TokenKind } from './tokenKind';
@@ -16,6 +16,11 @@ import { TokenKind } from './tokenKind';
1616
* whenever called.
1717
*/
1818
export class SchemaCoordinateLexer implements LexerInterface {
19+
/** @internal */
20+
public readonly _options: Readonly<LexerOptions>;
21+
/** @internal */
22+
public _tokenCounter: number;
23+
1924
source: Source;
2025

2126
/**
@@ -40,9 +45,11 @@ export class SchemaCoordinateLexer implements LexerInterface {
4045
*/
4146
lineStart: 0 = 0 as const;
4247

43-
constructor(source: Source) {
48+
constructor(source: Source, options: LexerOptions = {}) {
4449
const startOfFileToken = new Token(TokenKind.SOF, 0, 0, 0, 0);
4550

51+
this._options = options;
52+
this._tokenCounter = 0;
4653
this.source = source;
4754
this.lastToken = startOfFileToken;
4855
this.token = startOfFileToken;
@@ -52,6 +59,10 @@ export class SchemaCoordinateLexer implements LexerInterface {
5259
return 'SchemaCoordinateLexer';
5360
}
5461

62+
get tokenCount(): number {
63+
return this._tokenCounter;
64+
}
65+
5566
/**
5667
* Advances the token stream to the next non-ignored token.
5768
*/

0 commit comments

Comments
 (0)