This commit is contained in:
thosakwe 2017-01-22 18:15:53 -05:00
parent 15643e136b
commit b99eaf1965
40 changed files with 928 additions and 2 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
.packages
.project
.pub/
.scripts-bin/
build/
**/packages/

View file

@ -1,2 +1,2 @@
# graphql
Generates services and models from GraphQL.
# graphql_parser
Parses GraphQL queries and schemas.

17
example/basic.dart Normal file
View file

@ -0,0 +1,17 @@
import 'dart:async';
import 'package:graphql_parser/src/language/language.dart';
Stream<String> input() async* {
yield '''
{
project(name: "GraphQL") {
tagline
}
}
'''.trim();
}
main() async {
var lexer = new Lexer(), parser = new Parser();
await input().transform(lexer).forEach(print);
}

View file

@ -0,0 +1,16 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
class AliasContext extends Node {
final Token NAME1, COLON, NAME2;
AliasContext(this.NAME1, this.COLON, this.NAME2);
@override
SourceSpan get span =>
new SourceSpan(NAME1.span?.start, NAME2.span?.end, toSource());
@override
String toSource() => '${NAME1.text}:${NAME2.text}';
}

View file

@ -0,0 +1,18 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'value_or_variable.dart';
class ArgumentContext extends Node {
final Token NAME, COLON;
final ValueOrVariableContext valueOrVariable;
ArgumentContext(this.NAME, this.COLON, this.valueOrVariable);
@override
SourceSpan get span =>
new SourceSpan(NAME.span?.start, valueOrVariable.end, toSource());
@override
String toSource() => '${NAME.text}:${valueOrVariable.toSource()}';
}

View file

@ -0,0 +1,26 @@
import '../token.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
class ArrayValueContext extends ValueContext {
final Token LBRACKET, RBRACKET;
final List<ValueContext> values = [];
ArrayValueContext(this.LBRACKET, this.RBRACKET);
@override
SourceSpan get span =>
new SourceSpan(LBRACKET.span?.end, RBRACKET.span?.end, toSource());
@override
String toSource() {
var buf = new StringBuffer('[');
for (int i = 0; i < values.length; i++) {
if (i > 0) buf.write(',');
buf.write(values[i].toSource());
}
return buf.toString() + ']';
}
}

View file

@ -0,0 +1,29 @@
library graphql_parser.language.ast;
export 'alias.dart';
export 'argument.dart';
export 'boolean_value.dart';
export 'default_value.dart';
export 'definition.dart';
export 'directive.dart';
export 'document.dart';
export 'field.dart';
export 'field_name.dart';
export 'fragment_definition.dart';
export 'fragment_spread.dart';
export 'inline_fragment.dart';
export 'list_type.dart';
export 'node.dart';
export 'number_value.dart';
export 'operation_definition.dart';
export 'selection.dart';
export 'selection_set.dart';
export 'string_value.dart';
export 'type.dart';
export 'type_condition.dart';
export 'type_name.dart';
export 'value.dart';
export 'value_or_variable.dart';
export 'variable.dart';
export 'variable_definition.dart';
export 'variable_definitions.dart';

View file

@ -0,0 +1,15 @@
import '../token.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
class BooleanValueContext extends ValueContext {
final Token BOOLEAN;
BooleanValueContext(this.BOOLEAN);
@override
SourceSpan get span => BOOLEAN.span;
@override
String toSource() => BOOLEAN.text;
}

View file

@ -0,0 +1,18 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
class DefaultValueContext extends Node {
final Token EQUALS;
final ValueContext value;
DefaultValueContext(this.EQUALS, this.value);
@override
SourceSpan get span =>
new SourceSpan(EQUALS.span?.start, value.end, toSource());
@override
String toSource() => '=${value.toSource()}';
}

View file

@ -0,0 +1,5 @@
import 'node.dart';
abstract class DefinitionContext extends Node {
}

View file

@ -0,0 +1,37 @@
import '../token.dart';
import 'argument.dart';
import 'node.dart';
import 'package:source_span/source_span.dart';
import 'value_or_variable.dart';
class DirectiveContext extends Node {
final Token ARROBA, NAME, COLON, LPAREN, RPAREN;
final ArgumentContext argument;
final ValueOrVariableContext valueOrVariable;
DirectiveContext(this.ARROBA, this.NAME, this.COLON, this.LPAREN, this.RPAREN,
this.argument, this.valueOrVariable) {
assert(NAME != null);
}
@override
SourceSpan get span {
SourceLocation end = NAME.span?.end;
if (valueOrVariable != null)
end = valueOrVariable.end;
else if (RPAREN != null) end = RPAREN.span?.end;
return new SourceSpan(ARROBA.span?.start, end, toSource());
}
@override
String toSource() {
if (valueOrVariable != null)
return '@${NAME.text}:${valueOrVariable.toSource()}';
else if (argument != null)
return '@${NAME.text}(${argument.toSource()})';
else
return '@${NAME.text}';
}
}

View file

@ -0,0 +1,20 @@
import 'definition.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
class DocumentContext extends Node {
final List<DefinitionContext> definitions = [];
@override
SourceSpan get span {
if (definitions.isEmpty) return null;
return new SourceSpan(
definitions.first.start, definitions.last.end, toSource());
}
@override
String toSource() {
if (definitions.isEmpty) return '(empty document)';
return definitions.map((d) => d.toSource()).join();
}
}

View file

@ -0,0 +1,35 @@
import 'argument.dart';
import 'directive.dart';
import 'field_name.dart';
import 'node.dart';
import 'package:source_span/source_span.dart';
import 'selection_set.dart';
class FieldContext extends Node {
final FieldNameContext fieldName;
final List<ArgumentContext> arguments = [];
final List<DirectiveContext> directives = [];
final SelectionSetContext selectionSet;
FieldContext(this.fieldName, [this.selectionSet]);
@override
SourceSpan get span {
SourceLocation end = fieldName.end;
if (selectionSet != null)
end = selectionSet.end;
else if (directives.isNotEmpty)
end = directives.last.end;
else if (arguments.isNotEmpty) end = arguments.last.end;
return new SourceSpan(fieldName.start, end, toSource());
}
@override
String toSource() =>
fieldName.toSource() +
arguments.map((a) => a.toSource()).join() +
directives.map((d) => d.toSource()).join() +
(selectionSet?.toSource() ?? '');
}

View file

@ -0,0 +1,19 @@
import '../token.dart';
import 'alias.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
class FieldNameContext extends Node {
final Token NAME;
final AliasContext alias;
FieldNameContext(this.NAME, [this.alias]) {
assert(NAME != null || alias != null);
}
@override
SourceSpan get span => alias?.span ?? NAME.span;
@override
String toSource() => alias?.toSource() ?? NAME.text;
}

View file

@ -0,0 +1,29 @@
import '../token.dart';
import 'definition.dart';
import 'directive.dart';
import 'package:source_span/src/span.dart';
import 'selection_set.dart';
import 'type_condition.dart';
class FragmentDefinitionContext extends DefinitionContext {
final Token FRAGMENT, NAME, ON;
final TypeConditionContext typeCondition;
final List<DirectiveContext> directives = [];
final SelectionSetContext selectionSet;
String get name => NAME.text;
FragmentDefinitionContext(
this.FRAGMENT, this.NAME, this.ON, this.typeCondition, this.selectionSet);
@override
SourceSpan get span =>
new SourceSpan(FRAGMENT.span?.start, selectionSet.end, toSource());
@override
String toSource() =>
'fragment ${NAME.text} on ' +
typeCondition.toSource() +
directives.map((d) => d.toSource()).join() +
selectionSet.toSource();
}

View file

@ -0,0 +1,22 @@
import '../token.dart';
import 'directive.dart';
import 'node.dart';
import 'package:source_span/source_span.dart';
class FragmentSpreadContext extends Node {
final Token ELLIPSIS, NAME;
final List<DirectiveContext> directives = [];
FragmentSpreadContext(this.ELLIPSIS, this.NAME);
@override
SourceSpan get span {
SourceLocation end;
return new SourceSpan(ELLIPSIS.span?.start, end, toSource());
}
@override
String toSource() {
return '...${NAME.text}' + directives.map((d) => d.toSource()).join();
}
}

View file

@ -0,0 +1,26 @@
import '../token.dart';
import 'directive.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'selection_set.dart';
import 'type_condition.dart';
class InlineFragmentContext extends Node {
final Token ELLIPSIS, ON;
final TypeConditionContext typeCondition;
final List<DirectiveContext> directives = [];
final SelectionSetContext selectionSet;
InlineFragmentContext(
this.ELLIPSIS, this.ON, this.typeCondition, this.selectionSet);
@override
SourceSpan get span =>
new SourceSpan(ELLIPSIS.span?.start, selectionSet.end, toSource());
@override
String toSource() =>
'...on${typeCondition.toSource()}' +
directives.map((d) => d.toSource()).join() +
selectionSet.toSource();
}

View file

@ -0,0 +1,18 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'type.dart';
class ListTypeContext extends Node {
final Token LBRACKET, RBRACKET;
final TypeContext type;
ListTypeContext(this.LBRACKET, this.type, this.RBRACKET);
@override
SourceSpan get span =>
new SourceSpan(LBRACKET.span?.end, RBRACKET.span?.end, toSource());
@override
String toSource() => '[${type.toSource()}]';
}

View file

@ -0,0 +1,13 @@
import 'package:source_span/source_span.dart';
abstract class Node {
SourceSpan get span;
SourceLocation get start => span.start;
SourceLocation get end => span.end;
String toSource();
@override
String toString() => '${runtimeType}: ${toSource()}';
}

View file

@ -0,0 +1,15 @@
import '../token.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
class NumberValueContext extends ValueContext {
final Token NUMBER;
NumberValueContext(this.NUMBER);
@override
SourceSpan get span => NUMBER.span;
@override
String toSource() => NUMBER.text;
}

View file

@ -0,0 +1,37 @@
import '../token.dart';
import 'definition.dart';
import 'directive.dart';
import 'package:source_span/src/span.dart';
import 'selection_set.dart';
import 'variable_definitions.dart';
class OperationDefinitionContext extends DefinitionContext {
final Token TYPE, NAME;
final VariableDefinitionsContext variableDefinitions;
final List<DirectiveContext> directives = [];
final SelectionSetContext selectionSet;
bool get isMutation => TYPE.text == 'mutation';
bool get isQuery => TYPE.text == 'query';
String get name => NAME.text;
OperationDefinitionContext(
this.TYPE, this.NAME, this.variableDefinitions, this.selectionSet) {
assert(TYPE == null || TYPE.text == 'query' || TYPE.text == 'mutation');
}
@override
SourceSpan get span {
if (TYPE == null) return selectionSet.span;
return new SourceSpan(TYPE.span?.start, selectionSet.end, toSource());
}
@override
String toSource() {
if (TYPE == null) return selectionSet.toSource();
return '${TYPE.text} ${NAME.text} ${variableDefinitions.toSource()} ' +
directives.map((d) => d.toSource()).join() +
' ${selectionSet.toSource()}';
}
}

View file

@ -0,0 +1,25 @@
import 'field.dart';
import 'fragment_spread.dart';
import 'inline_fragment.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
class SelectionContext extends Node {
final FieldContext field;
final FragmentSpreadContext fragmentSpread;
final InlineFragmentContext inlineFragment;
SelectionContext(this.field, [this.fragmentSpread, this.inlineFragment]) {
assert(field != null || fragmentSpread != null || inlineFragment != null);
}
@override
SourceSpan get span =>
field?.span ?? fragmentSpread?.span ?? inlineFragment?.span;
@override
String toSource() =>
field?.toSource() ??
fragmentSpread?.toSource() ??
inlineFragment?.toSource();
}

View file

@ -0,0 +1,27 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'selection.dart';
class SelectionSetContext extends Node {
final Token LBRACE, RBRACE;
final List<SelectionContext> selections = [];
SelectionSetContext(this.LBRACE, this.RBRACE);
@override
SourceSpan get span =>
new SourceSpan(LBRACE.span?.start, RBRACE.span?.end, toSource());
@override
String toSource() {
var buf = new StringBuffer('{');
for (int i = 0; i < selections.length; i++) {
if (i > 0) buf.write(',');
buf.write(selections[i].toSource());
}
return buf.toString() + '}';
}
}

View file

@ -0,0 +1,15 @@
import '../token.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
class StringValueContext extends ValueContext {
final Token STRING;
StringValueContext(this.STRING);
@override
SourceSpan get span => STRING.span;
@override
String toSource() => STRING.text;
}

View file

@ -0,0 +1,49 @@
import 'package:source_span/source_span.dart';
import '../token.dart';
import 'list_type.dart';
import 'node.dart';
import 'type_name.dart';
class TypeContext extends Node {
final Token EXCLAMATION;
final TypeNameContext typeName;
final ListTypeContext listType;
bool get nonNullType => EXCLAMATION != null;
TypeContext(this.typeName, this.listType, [this.EXCLAMATION]) {
assert(typeName != null || listType != null);
}
@override
SourceSpan get span {
SourceLocation start, end;
if (typeName != null) {
start = typeName.start;
end = typeName.end;
} else if (listType != null) {
start = listType.start;
end = listType.end;
}
if (EXCLAMATION != null) end = EXCLAMATION.span?.end;
return new SourceSpan(start, end, toSource());
}
@override
String toSource() {
var buf = new StringBuffer();
if (typeName != null) {
buf.write(typeName.toSource());
} else if (listType != null) {
buf.write(listType.toSource());
}
if (nonNullType) buf.write('!');
return buf.toString();
}
}

View file

@ -0,0 +1,15 @@
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'type_name.dart';
class TypeConditionContext extends Node {
final TypeNameContext typeName;
TypeConditionContext(this.typeName);
@override
SourceSpan get span => typeName.span;
@override
String toSource() => typeName.toSource();
}

View file

@ -0,0 +1,15 @@
import 'node.dart';
import 'package:source_span/src/span.dart';
import '../token.dart';
class TypeNameContext extends Node {
final Token NAME;
@override
SourceSpan get span => NAME.span;
TypeNameContext(this.NAME);
@override
String toSource() => NAME.text;
}

View file

@ -0,0 +1,3 @@
import 'node.dart';
abstract class ValueContext extends Node {}

View file

@ -0,0 +1,19 @@
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'value.dart';
import 'variable.dart';
class ValueOrVariableContext extends Node {
final ValueContext value;
final VariableContext variable;
ValueOrVariableContext(this.value, this.variable) {
assert(value != null || variable != null);
}
@override
SourceSpan get span => value?.span ?? variable.span;
@override
String toSource() => '${value?.toSource() ?? variable.toSource()}';
}

View file

@ -0,0 +1,16 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
class VariableContext extends Node {
final Token DOLLAR, NAME;
VariableContext(this.DOLLAR, this.NAME);
@override
SourceSpan get span =>
new SourceSpan(DOLLAR?.span?.start, NAME?.span?.end, toSource());
@override
String toSource() => '\$${NAME.text}';
}

View file

@ -0,0 +1,24 @@
import '../token.dart';
import 'node.dart';
import 'default_value.dart';
import 'package:source_span/src/span.dart';
import 'type.dart';
import 'variable.dart';
class VariableDefinitionContext extends Node {
final Token COLON;
final VariableContext variable;
final TypeContext type;
final DefaultValueContext defaultValue;
VariableDefinitionContext(this.variable, this.COLON, this.type,
[this.defaultValue]);
@override
SourceSpan get span =>
new SourceSpan(variable.start, defaultValue?.end ?? type.end, toSource());
@override
String toSource() =>
'${variable.toSource()}:${type.toSource()}${defaultValue?.toSource() ?? ""}';
}

View file

@ -0,0 +1,28 @@
import '../token.dart';
import 'node.dart';
import 'package:source_span/src/span.dart';
import 'variable_definition.dart';
class VariableDefinitionsContext extends Node {
final Token LPAREN, RPAREN;
final List<VariableDefinitionContext> variableDefinitions = [];
VariableDefinitionsContext(this.LPAREN, this.RPAREN);
@override
SourceSpan get span =>
new SourceSpan(LPAREN.span?.end, RPAREN.span?.end, toSource());
@override
String toSource() {
var buf = new StringBuffer('[');
for (int i = 0; i < variableDefinitions.length; i++) {
if (i > 0) buf.write(',');
buf.write(variableDefinitions[i].toSource());
}
buf.write(']');
return buf.toString();
}
}

View file

@ -0,0 +1,6 @@
library graphql_parser.language;
export 'lexer.dart';
export 'parser.dart';
export 'token.dart';
export 'token_type.dart';

View file

@ -0,0 +1,93 @@
import 'dart:async';
import 'package:string_scanner/string_scanner.dart';
import 'package:source_span/source_span.dart';
import 'syntax_error.dart';
import 'token.dart';
import 'token_type.dart';
final RegExp _whitespace = new RegExp('[ \t\n\r]+');
final RegExp _boolean = new RegExp(r'true|false');
final RegExp _number = new RegExp(r'-?[0-9]+(\.[0-9]+)?(E|e(\+|-)?[0-9]+)?');
final RegExp _string = new RegExp(
r'"((\\(["\\/bfnrt]|(u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))|([^"\\]))*"');
final RegExp _name = new RegExp(r'[_A-Za-z][_0-9A-Za-z]*');
final Map<Pattern, TokenType> _patterns = {
'@': TokenType.ARROBA,
':': TokenType.COLON,
',': TokenType.COMMA,
r'$': TokenType.DOLLAR,
'...': TokenType.ELLIPSIS,
'=': TokenType.EQUALS,
'!': TokenType.EXCLAMATION,
'{': TokenType.LBRACE,
'}': TokenType.RBRACE,
'[': TokenType.LBRACKET,
']': TokenType.RBRACKET,
'(': TokenType.LPAREN,
')': TokenType.RPAREN,
'fragment': TokenType.FRAGMENT,
'mutation': TokenType.MUTATION,
'on': TokenType.ON,
'query': TokenType.QUERY,
_boolean: TokenType.BOOLEAN,
_number: TokenType.NUMBER,
_string: TokenType.STRING,
_name: TokenType.NAME
};
class Lexer implements StreamTransformer<String, Token> {
@override
Stream<Token> bind(Stream<String> stream) {
var ctrl = new StreamController<Token>();
stream.listen((str) {
var scanner = new StringScanner(str);
int line = 1, column = 1;
while (!scanner.isDone) {
List<Token> potential = [];
if (scanner.scan(_whitespace)) {
var text = scanner.lastMatch[0];
line += '\n'.allMatches(text).length;
var lastNewLine = text.lastIndexOf('\n');
if (lastNewLine != -1) {
int len = text.substring(lastNewLine + 1).length;
column = 1 + len;
}
continue;
}
for (var pattern in _patterns.keys) {
if (scanner.matches(pattern)) {
potential.add(new Token(_patterns[pattern], scanner.lastMatch[0]));
}
}
if (potential.isEmpty) {
var ch = new String.fromCharCode(scanner.readChar());
ctrl.addError(new SyntaxError("Unexpected token '$ch'.", line, column));
} else {
// Choose longest token
potential.sort((a, b) => a.text.length.compareTo(b.text.length));
var chosen = potential.first;
var start =
new SourceLocation(scanner.position, line: line, column: column);
ctrl.add(chosen);
scanner.position += chosen.text.length;
column += chosen.text.length;
var end =
new SourceLocation(scanner.position, line: line, column: column);
chosen.span = new SourceSpan(start, end, chosen.text);
}
}
})
..onDone(ctrl.close)
..onError(ctrl.addError);
return ctrl.stream;
}
}

View file

@ -0,0 +1,26 @@
import 'dart:async';
import 'ast/ast.dart';
import 'stream_reader.dart';
import 'token.dart';
class Parser implements StreamConsumer<Token> {
bool _closed = false;
final StreamReader<Token> _reader = new StreamReader();
final StreamController<Node> _onNode = new StreamController<Node>();
Stream<Node> get onNode => _onNode.stream;
@override
Future addStream(Stream<Token> stream) async {
if (_closed) throw new StateError('Parser is already closed.');
stream.pipe(_reader);
}
@override
Future close() async {
_closed = true;
await _onNode.close();
}
}

View file

@ -0,0 +1,79 @@
import 'dart:async';
import 'dart:collection';
class StreamReader<T> implements StreamConsumer<T> {
final Queue<T> _buffer = new Queue();
bool _closed = false;
final Queue<Completer<T>> _nextQueue = new Queue();
final Queue<Completer<T>> _peekQueue = new Queue();
bool get isDone => _closed;
Future<T> peek() {
if (isDone) throw new StateError('Cannot read from closed stream.');
if (_buffer.isNotEmpty) return new Future.value(_buffer.first);
var c = new Completer<T>();
_peekQueue.addLast(c);
return c.future;
}
Future<T> next() {
if (isDone) throw new StateError('Cannot read from closed stream.');
if (_buffer.isNotEmpty) return new Future.value(_buffer.removeFirst());
var c = new Completer<T>();
_nextQueue.addLast(c);
return c.future;
}
@override
Future addStream(Stream<T> stream) {
if (_closed) throw new StateError('StreamReader has already been used.');
var c = new Completer();
stream.listen((data) {
if (_peekQueue.isNotEmpty || _nextQueue.isNotEmpty) {
if (_peekQueue.isNotEmpty) {
_peekQueue.removeFirst().complete(data);
}
if (_nextQueue.isNotEmpty) {
_nextQueue.removeFirst().complete(data);
}
} else {
_buffer.add(data);
}
})
..onDone(c.complete)
..onError(c.completeError);
return c.future;
}
@override
Future close() async {
_closed = true;
}
}
class _IteratorReader<T> {
final Iterator<T> _tokens;
T _current;
_IteratorReader(this._tokens) {
_tokens.moveNext();
}
T advance() {
_current = _tokens.current;
_tokens.moveNext();
return _current;
}
bool get eof => _tokens.current == null;
T peek() => _tokens.current;
}

View file

@ -0,0 +1,18 @@
import 'package:source_span/source_span.dart';
import 'token.dart';
class SyntaxError implements Exception {
final String message;
final int line, column;
final Token offendingToken;
SyntaxError(this.message, this.line, this.column, [this.offendingToken]);
factory SyntaxError.fromSourceLocation(
String message, SourceLocation location,
[Token offendingToken]) =>
new SyntaxError(message, location.line, location.column, offendingToken);
@override
String toString() => 'Syntax error at line $line, column $column: $message';
}

View file

@ -0,0 +1,18 @@
import 'package:source_span/source_span.dart';
import 'token_type.dart';
class Token {
final TokenType type;
final String text;
SourceSpan span;
Token(this.type, this.text, [this.span]);
@override
String toString() {
if (span == null)
return "'$text' -> $type";
else
return "(${span.start.line}:${span.start.column}) '$text' -> $type";
}
}

View file

@ -0,0 +1,26 @@
enum TokenType {
ARROBA,
COLON,
COMMA,
DOLLAR,
ELLIPSIS,
EQUALS,
EXCLAMATION,
LBRACE,
RBRACE,
LBRACKET,
RBRACKET,
LPAREN,
RPAREN,
FRAGMENT,
MUTATION,
ON,
QUERY,
BOOLEAN,
NUMBER,
STRING,
NAME
}

8
pubspec.yaml Normal file
View file

@ -0,0 +1,8 @@
author: "Tobe O"
description: "Parses GraphQL queries and schemas."
homepage: "https://github.com/thosakwe/graphql_parser"
name: "graphql_parser"
version: "0.0.0"
dependencies:
source_span: "^1.3.1"
string_scanner: "^1.0.1"