diff --git a/CHANGELOG.md b/CHANGELOG.md index d23c0da..f27d01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## [Unreleased] +### Added +- Tokenizer and AST + ## 0.0.0+dev.0 - 2020-07-24 ### Added - Basic design draft diff --git a/lib/jessie.dart b/lib/jessie.dart index 82130dd..ce0a1fd 100644 --- a/lib/jessie.dart +++ b/lib/jessie.dart @@ -1,8 +1,4 @@ /// JSONPath for Dart library jessie; -export 'package:jessie/src/field.dart'; -export 'package:jessie/src/filter.dart'; -export 'package:jessie/src/index.dart'; -export 'package:jessie/src/neutral.dart'; -export 'package:jessie/src/tokenizer.dart'; +export 'package:jessie/src/json_path.dart'; diff --git a/lib/src/ast.dart b/lib/src/ast.dart new file mode 100644 index 0000000..74cb7d8 --- /dev/null +++ b/lib/src/ast.dart @@ -0,0 +1,38 @@ +class Node { + Node(this.value); + + static Node build(List tokens) { + final root = Node(r'$'); + if (tokens.isEmpty) { + return root; + } + final reversed = [...tokens.reversed]; + if (reversed.last == r'$') { + reversed.removeLast(); + } + final stack = [root]; + while (reversed.isNotEmpty) { + final token = reversed.removeLast(); + if (token == '[') { + stack.add(Node(token)); + continue; + } + if (token == ']' || token == ')') { + final search = token == ']' ? '[' : '('; + final children = []; + while (stack.last.value != search) { + children.add(stack.removeLast()); + } + final brackets = stack.removeLast(); + brackets.children.addAll(children); + stack.last.children.add(brackets); + continue; + } + stack.last.children.add(Node(token)); + } + return stack.last; + } + + final String value; + final children = []; +} diff --git a/lib/src/field.dart b/lib/src/field.dart index e24334d..55428f0 100644 --- a/lib/src/field.dart +++ b/lib/src/field.dart @@ -1,4 +1,5 @@ import 'package:jessie/src/filter.dart'; +import 'package:jessie/src/match.dart'; class Field extends Filter { Field(this.name); @@ -6,9 +7,9 @@ class Field extends Filter { final String name; @override - Iterable call(Iterable nodes) => nodes - .where((node) => node is Map && node.containsKey(name)) - .map((node) => node[name]); + Iterable call(Iterable matches) => matches + .where((m) => m.value is Map && m.value.containsKey(name)) + .map((m) => PathMatch(m.value[name], m.path + toString())); @override String toString() => "['$name']"; diff --git a/lib/src/filter.dart b/lib/src/filter.dart index 733cbc8..f4d8e03 100644 --- a/lib/src/filter.dart +++ b/lib/src/filter.dart @@ -1,8 +1,10 @@ +import 'package:jessie/src/match.dart'; + abstract class Filter { const Filter(); - /// Applies this JSONPath to the [nodes] - Iterable call(Iterable nodes); + /// Applies this filter to the [matches] + Iterable call(Iterable matches); /// The string expression without leading `$` @override @@ -23,7 +25,8 @@ class _Chain extends Filter { final Filter second; @override - Iterable call(Iterable nodes) => second(first(nodes)); + Iterable call(Iterable matches) => + second(first(matches)); @override String toString() => '$first$second'; diff --git a/lib/src/index.dart b/lib/src/index.dart index a571f51..f1626c9 100644 --- a/lib/src/index.dart +++ b/lib/src/index.dart @@ -1,4 +1,5 @@ import 'package:jessie/src/filter.dart'; +import 'package:jessie/src/match.dart'; class Index extends Filter { Index(this.index); @@ -6,9 +7,9 @@ class Index extends Filter { final int index; @override - Iterable call(Iterable nodes) => nodes - .where((node) => node is List && node.length > index + 1) - .map((node) => node[index]); + Iterable call(Iterable matches) => matches + .where((m) => m.value is List && m.value.length > index + 1) + .map((m) => PathMatch(m.value[index], m.path + toString())); @override String toString() => '[$index]'; diff --git a/lib/src/json_path.dart b/lib/src/json_path.dart new file mode 100644 index 0000000..c56932f --- /dev/null +++ b/lib/src/json_path.dart @@ -0,0 +1,27 @@ +import 'package:jessie/src/ast.dart'; +import 'package:jessie/src/filter.dart'; +import 'package:jessie/src/match.dart'; +import 'package:jessie/src/root.dart'; +import 'package:jessie/src/state.dart'; +import 'package:jessie/src/tokenize.dart'; + +class JsonPath { + factory JsonPath(String expression) { + State state = Ready(Root()); + for (final node in Node.build(tokenize(expression)).children) { + state = state.process(node); + } + return JsonPath._(state.filter); + } + + JsonPath._(this._filter); + + final Filter _filter; + + /// Filters the given [json]. + /// Returns an Iterable of all elements found + Iterable filter(json) => _filter.call([PathMatch(json, '')]); + + @override + String toString() => _filter.toString(); +} diff --git a/lib/src/match.dart b/lib/src/match.dart new file mode 100644 index 0000000..4182d65 --- /dev/null +++ b/lib/src/match.dart @@ -0,0 +1,7 @@ +class PathMatch { + PathMatch(this.value, this.path); + + final T value; + + final String path; +} diff --git a/lib/src/neutral.dart b/lib/src/neutral.dart deleted file mode 100644 index 60dc870..0000000 --- a/lib/src/neutral.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:jessie/jessie.dart'; - -class Neutral extends Filter { - const Neutral(); - - @override - Iterable call(Iterable nodes) => nodes; - - @override - String toString() => r'$'; -} diff --git a/lib/src/root.dart b/lib/src/root.dart new file mode 100644 index 0000000..3fdc028 --- /dev/null +++ b/lib/src/root.dart @@ -0,0 +1,13 @@ +import 'package:jessie/src/filter.dart'; +import 'package:jessie/src/match.dart'; + +class Root extends Filter { + const Root(); + + @override + Iterable call(Iterable matches) => + matches.map((m) => PathMatch(m.value, toString())); + + @override + String toString() => r'$'; +} diff --git a/lib/src/state.dart b/lib/src/state.dart new file mode 100644 index 0000000..88b59d4 --- /dev/null +++ b/lib/src/state.dart @@ -0,0 +1,40 @@ +import 'package:jessie/src/ast.dart'; +import 'package:jessie/src/field.dart'; +import 'package:jessie/src/filter.dart'; +import 'package:jessie/src/index.dart'; + +abstract class State { + State process(Node node); + + Filter get filter; +} + +class Ready implements State { + Ready(this.filter); + + @override + final Filter filter; + + @override + State process(Node node) { + if (node.value == '[') { + return Ready(filter.then(Index(int.parse(node.children.first.value)))); + } + if (node.value == '.') { + return AwaitingField(filter); + } + throw StateError('Got ${node.value} in $this'); + } +} + +class AwaitingField implements State { + AwaitingField(this.filter); + + @override + final Filter filter; + + @override + State process(Node node) { + return Ready(filter.then(Field(node.value))); + } +} diff --git a/lib/src/tokenize.dart b/lib/src/tokenize.dart new file mode 100644 index 0000000..e8ce9e8 --- /dev/null +++ b/lib/src/tokenize.dart @@ -0,0 +1,49 @@ +List tokenize(String expr) { + final tokens = []; + var pos = 0; + while (pos < expr.length) { + var token = ''; + while (pos < expr.length) { + if (_singles.contains(expr[pos])) { + if (token.isNotEmpty) { + break; + } + if (expr.length > pos + 1) { + final nextTwoChars = expr.substring(pos, pos + 1); + if (_doubles.contains(nextTwoChars)) { + token = nextTwoChars; + pos += 2; + break; + } + } + token = expr[pos++]; + break; + } + token += expr[pos++]; + } + tokens.add(token); + } + return tokens; +} + +const _singles = [ + r'$', + '[', + ']', + '(', + ')', + '?', + '.', + '*', + '@', + '<', + '>', + '-', + '=', + '&', + '|', + ':', + ' ' +]; + +const _doubles = ['..', '&&', '||']; diff --git a/lib/src/tokenizer.dart b/lib/src/tokenizer.dart deleted file mode 100644 index d715a1c..0000000 --- a/lib/src/tokenizer.dart +++ /dev/null @@ -1,129 +0,0 @@ -List tokenize(String expression) { - final tokens = []; - var exp = expression; - while (exp.isNotEmpty) { - switch (exp[0]) { - case r'$': - tokens.add(const Root()); - exp = exp.substring(1); - break; - case '*': - tokens.add(const Asterisk()); - exp = exp.substring(1); - break; - case '[': - tokens.add(const LeftBracket()); - exp = exp.substring(1); - break; - case ']': - tokens.add(const RightBracket()); - exp = exp.substring(1); - break; - case '.': - if (exp.length > 1 && exp[1] == '.') { - tokens.add(const DoublePeriod()); - exp = exp.substring(2); - } else { - tokens.add(const Period()); - exp = exp.substring(1); - } - break; - default: - if (exp.startsWith(_digit)) { - final num = _allDigits.firstMatch(exp).group(0); - tokens.add(Number(int.parse(num))); - exp = exp.substring(num.length); - } else if (exp.startsWith(_char)) { - final text = _allAlphanumeric.firstMatch(exp).group(0); - tokens.add(Name(text)); - exp = exp.substring(text.length); - } else { - throw 'Can not tokenize "$exp"'; - } - } - } - return tokens; -} - -final _char = RegExp(r'[a-zA-Z]'); -final _allAlphanumeric = RegExp(r'[a-zA-Z][a-zA-Z0-9]+'); -final _digit = RegExp(r'\d'); -final _allDigits = RegExp(r'\d+'); - -abstract class Token {} - -class Root implements Token { - const Root(); - - @override - String toString() => r'$'; -} - -class Period implements Token { - const Period(); - - @override - String toString() => '.'; -} - -class DoublePeriod implements Token { - const DoublePeriod(); - - @override - String toString() => '..'; -} - -class Name implements Token { - const Name(this.value); - - final String value; - - @override - String toString() => value; -} - -class Number implements Token { - const Number(this.value); - - final int value; - - @override - String toString() => value.toString(); -} - -class Asterisk implements Token { - const Asterisk(); - - @override - String toString() => '*'; -} - -class LeftBracket implements Token { - const LeftBracket(); - - @override - String toString() => '['; -} - -class RightBracket implements Token { - const RightBracket(); - - @override - String toString() => ']'; -} - -//class LeftParenthesis implements Token { -// const LeftParenthesis(); -//} -// -//class RightParenthesis implements Token { -// const RightParenthesis(); -//} -// -//class Colon implements Token { -// const Colon(); -//} -// -//class Comma implements Token { -// const Comma(); -//} diff --git a/test/functional_test.dart b/test/functional_test.dart deleted file mode 100644 index 0996551..0000000 --- a/test/functional_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:jessie/jessie.dart'; -import 'package:test/test.dart'; - -void main() { - final store = jsonDecode(File('test/store.json').readAsStringSync()); - group('Basic', () { - final path = JsonPath('store.book[0].title'); - test('Filtering', () { - expect(path.filter(store).single, 'Sayings of the Century'); - }); - test('toString()', () { - expect(path.toString(), r"$['store']['book'][0]['title']"); - }); - }); - - group('Tokenizer', () { - test('Basic', () { - expect(tokenize('store.book[0].title').join(), 'store.book[0].title'); - }); - }); -} - -class JsonPath { - factory JsonPath(String path) { - final tokens = tokenize(path); - if (tokens.isNotEmpty && tokens.first == const Root()) { - tokens.removeAt(0); - } - - var filter = const Neutral(); - - return JsonPath._( - Neutral() | Field('store') | Field('book') | Index(0) | Field('title')); - } - - JsonPath._(this._filter); - - final Filter _filter; - - /// Filters the given [json]. - /// Returns an Iterable of all elements found - Iterable filter(json) => _filter.call([json]); - - @override - String toString() => _filter.toString(); -} diff --git a/test/json_path_test.dart b/test/json_path_test.dart new file mode 100644 index 0000000..7f0ecb2 --- /dev/null +++ b/test/json_path_test.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:jessie/jessie.dart'; +import 'package:test/test.dart'; + +void main() { + final store = jsonDecode(File('test/store.json').readAsStringSync()); + group('Basic expressions', () { + test('Empty', () { + final path = JsonPath(''); + expect(path.toString(), r'$'); + expect(path.filter(store).single.value, store); + expect(path.filter(store).single.path, r'$'); + }); + + test('Only root', () { + final path = JsonPath(r'$'); + expect(path.toString(), r'$'); + expect(path.filter(store).single.value, store); + expect(path.filter(store).single.path, r'$'); + }); + + test('Single field', () { + final path = JsonPath(r'$.store'); + expect(path.toString(), r"$['store']"); + expect(path.filter(store).single.value, store['store']); + expect(path.filter(store).single.path, r"$['store']"); + }); + + test('Path with an index', () { + final path = JsonPath(r'$.store.book[0].title'); + expect(path.toString(), r"$['store']['book'][0]['title']"); + expect(path.filter(store).single.value, 'Sayings of the Century'); + expect(path.filter(store).single.path, r"$['store']['book'][0]['title']"); + }); + }); +}