graphql-language-service
Version:
The official, runtime independent Language Service for GraphQL
356 lines (320 loc) • 10.1 kB
text/typescript
/**
* Copyright (c) 2021 GraphQL Contributors
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*
*/
/**
* Builds an online immutable parser, designed to be used as part of a syntax
* highlighting and code intelligence tools.
*
* Options:
*
* eatWhitespace: (
* stream: Stream | CodeMirror.StringStream | CharacterStream
* ) => boolean
* Use CodeMirror API.
*
* LexRules: { [name: string]: RegExp }, Includes `Punctuation`, `Comment`.
*
* ParseRules: { [name: string]: Array<Rule> }, Includes `Document`.
*
* editorConfig: { [name: string]: any }, Provides an editor-specific
* configurations set.
*
*/
import CharacterStream from './CharacterStream';
import { State, Token, Rule, RuleKind } from './types';
import { LexRules, ParseRules, isIgnored } from './Rules';
import { Kind } from 'graphql';
export type ParserOptions = {
eatWhitespace: (stream: CharacterStream) => boolean;
lexRules: Partial<typeof LexRules>;
parseRules: typeof ParseRules;
editorConfig: { [name: string]: any };
};
export default function onlineParser(
options: ParserOptions = {
eatWhitespace: stream => stream.eatWhile(isIgnored),
lexRules: LexRules,
parseRules: ParseRules,
editorConfig: {},
},
): {
startState: () => State;
token: (stream: CharacterStream, state: State) => string;
} {
return {
startState() {
const initialState = {
level: 0,
step: 0,
name: null,
kind: null,
type: null,
rule: null,
needsSeparator: false,
prevState: null,
};
pushRule(options.parseRules, initialState, Kind.DOCUMENT);
return initialState;
},
token(stream: CharacterStream, state: State) {
return getToken(stream, state, options);
},
};
}
function getToken(
stream: CharacterStream,
state: State,
options: ParserOptions,
): string {
if (state.inBlockstring) {
// eslint-disable-next-line unicorn/prefer-regexp-test -- false positive stream is not string
if (stream.match(/.*"""/)) {
state.inBlockstring = false;
return 'string';
}
stream.skipToEnd();
return 'string';
}
const { lexRules, parseRules, eatWhitespace, editorConfig } = options;
// Restore state after an empty-rule.
if (state.rule && state.rule.length === 0) {
popRule(state);
} else if (state.needsAdvance) {
state.needsAdvance = false;
advanceRule(state, true);
}
// Remember initial indentation
if (stream.sol()) {
const tabSize = editorConfig?.tabSize || 2;
state.indentLevel = Math.floor(stream.indentation() / tabSize);
}
// Consume spaces and ignored characters
if (eatWhitespace(stream)) {
return 'ws';
}
// Get a matched token from the stream, using lex
const token = lex(lexRules, stream);
// If there's no matching token, skip ahead.
if (!token) {
const matchedSomething = stream.match(/\S+/);
if (!matchedSomething) {
// We need to eat at least one character, and we couldn't match any
// non-whitespace, so it must be exotic whitespace.
stream.match(/\s/);
}
pushRule(SpecialParseRules, state, 'Invalid');
return 'invalidchar';
}
// If the next token is a Comment, insert a Comment parsing rule.
if (token.kind === 'Comment') {
pushRule(SpecialParseRules, state, 'Comment');
return 'comment';
}
// Save state before continuing.
const backupState = assign({}, state);
// Handle changes in expected indentation level
if (token.kind === 'Punctuation') {
if (/^[{([]/.test(token.value)) {
if (state.indentLevel !== undefined) {
// Push on the stack of levels one level deeper than the current level.
state.levels = (state.levels || []).concat(state.indentLevel + 1);
}
} else if (/^[})\]]/.test(token.value)) {
// Pop from the stack of levels.
// If the top of the stack is lower than the current level, lower the
// current level to match.
const levels = (state.levels = (state.levels || []).slice(0, -1));
// FIXME
// what if state.indentLevel === 0?
if (
state.indentLevel &&
levels.length > 0 &&
levels.at(-1)! < state.indentLevel
) {
state.indentLevel = levels.at(-1);
}
}
}
while (state.rule) {
// If this is a forking rule, determine what rule to use based on
// the current token, otherwise expect based on the current step.
let expected: any =
typeof state.rule === 'function'
? state.step === 0
? state.rule(token, stream)
: null
: state.rule[state.step];
// Separator between list elements if necessary.
if (state.needsSeparator) {
expected = expected?.separator;
}
if (expected) {
// Un-wrap optional/list parseRules.
if (expected.ofRule) {
expected = expected.ofRule;
}
// A string represents a Rule
if (typeof expected === 'string') {
pushRule(parseRules, state, expected as RuleKind);
continue;
}
// Otherwise, match a Terminal.
if (expected.match?.(token)) {
if (expected.update) {
expected.update(state, token);
}
// If this token was a punctuator, advance the parse rule, otherwise
// mark the state to be advanced before the next token. This ensures
// that tokens which can be appended to keep the appropriate state.
if (token.kind === 'Punctuation') {
advanceRule(state, true);
} else {
state.needsAdvance = true;
}
return expected.style;
}
}
unsuccessful(state);
}
// The parser does not know how to interpret this token, do not affect state.
assign(state, backupState);
pushRule(SpecialParseRules, state, 'Invalid');
return 'invalidchar';
}
// Utility function to assign from object to another object.
function assign(to: Object, from: Object): Object {
const keys = Object.keys(from);
for (let i = 0; i < keys.length; i++) {
// @ts-ignore
// TODO: ParseRules as numerical index
to[keys[i]] = from[keys[i]];
}
return to;
}
// A special rule set for parsing comment tokens.
const SpecialParseRules = {
Invalid: [],
Comment: [],
};
// Push a new rule onto the state.
function pushRule(
rules: typeof ParseRules,
state: State,
ruleKind: RuleKind,
): void {
if (!rules[ruleKind]) {
throw new TypeError('Unknown rule: ' + ruleKind);
}
state.prevState = { ...state };
state.kind = ruleKind;
state.name = null;
state.type = null;
state.rule = rules[ruleKind];
state.step = 0;
state.needsSeparator = false;
}
// Pop the current rule from the state.
function popRule(state: State): undefined {
// Check if there's anything to pop
if (!state.prevState) {
return;
}
state.kind = state.prevState.kind;
state.name = state.prevState.name;
state.type = state.prevState.type;
state.rule = state.prevState.rule;
state.step = state.prevState.step;
state.needsSeparator = state.prevState.needsSeparator;
state.prevState = state.prevState.prevState;
}
// Advance the step of the current rule.
function advanceRule(state: State, successful: boolean): undefined {
// If this is advancing successfully and the current state is a list, give
// it an opportunity to repeat itself.
if (isList(state) && state.rule) {
// @ts-ignore
// TODO: ParseRules as numerical index
const step = state.rule[state.step];
if (step.separator) {
const { separator } = step;
state.needsSeparator = !state.needsSeparator;
// If the separator was optional, then give it an opportunity to repeat.
if (!state.needsSeparator && separator.ofRule) {
return;
}
}
// If this was a successful list parse, then allow it to repeat itself.
if (successful) {
return;
}
}
// Advance the step in the rule. If the rule is completed, pop
// the rule and advance the parent rule as well (recursively).
state.needsSeparator = false;
state.step++;
// While the current rule is completed.
while (
state.rule &&
!(Array.isArray(state.rule) && state.step < state.rule.length)
) {
popRule(state);
if (state.rule) {
// Do not advance a List step so it has the opportunity to repeat itself.
if (isList(state)) {
// @ts-ignore
// TODO: ParseRules as numerical index
if (state.rule?.[state.step].separator) {
state.needsSeparator = !state.needsSeparator;
}
} else {
state.needsSeparator = false;
state.step++;
}
}
}
}
function isList(state: State): boolean | null | undefined {
const step =
Array.isArray(state.rule) &&
typeof state.rule[state.step] !== 'string' &&
(state.rule[state.step] as Rule);
return step && step.isList;
}
// Unwind the state after an unsuccessful match.
function unsuccessful(state: State): void {
// Fall back to the parent rule until you get to an optional or list rule or
// until the entire stack of rules is empty.
while (
state.rule &&
// TODO: not sure how to fix this in a performant way
// @ts-ignore
!(Array.isArray(state.rule) && state.rule[state.step].ofRule)
) {
popRule(state);
}
// If there is still a rule, it must be an optional or list rule.
// Consider this rule a success so that we may move past it.
if (state.rule) {
advanceRule(state, false);
}
}
// Given a stream, returns a { kind, value } pair, or null.
function lex(
lexRules: Partial<typeof LexRules>,
stream: CharacterStream,
): Token | null | undefined {
const kinds = Object.keys(lexRules);
for (let i = 0; i < kinds.length; i++) {
// @ts-ignore
// TODO: ParseRules as numerical index
const match = stream.match(lexRules[kinds[i]]);
if (match && match instanceof Array) {
return { kind: kinds[i], value: match[0] };
}
}
}