aspen-cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/FUNDING.yml +12 -0
- data/.gitignore +16 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/Rakefile +10 -0
- data/aspen.gemspec +48 -0
- data/bin/aspen +3 -0
- data/bin/console +22 -0
- data/bin/setup +8 -0
- data/lib/aspen/abstract_parser.rb +72 -0
- data/lib/aspen/abstract_statement.rb +34 -0
- data/lib/aspen/actions/compile.rb +31 -0
- data/lib/aspen/actions/push.rb +39 -0
- data/lib/aspen/actions/watch.rb +55 -0
- data/lib/aspen/actions.rb +8 -0
- data/lib/aspen/adapters.rb +38 -0
- data/lib/aspen/ast/nodes/attribute.rb +16 -0
- data/lib/aspen/ast/nodes/comment.rb +15 -0
- data/lib/aspen/ast/nodes/content.rb +17 -0
- data/lib/aspen/ast/nodes/custom_statement.rb +19 -0
- data/lib/aspen/ast/nodes/edge.rb +15 -0
- data/lib/aspen/ast/nodes/label.rb +15 -0
- data/lib/aspen/ast/nodes/narrative.rb +15 -0
- data/lib/aspen/ast/nodes/node.rb +20 -0
- data/lib/aspen/ast/nodes/statement.rb +17 -0
- data/lib/aspen/ast/nodes/type.rb +46 -0
- data/lib/aspen/ast.rb +18 -0
- data/lib/aspen/cli/commands/build.rb +26 -0
- data/lib/aspen/cli/commands/build_steps.rb +204 -0
- data/lib/aspen/cli/commands/compile.rb +27 -0
- data/lib/aspen/cli/commands/generate.rb +23 -0
- data/lib/aspen/cli/commands/new.rb +115 -0
- data/lib/aspen/cli/commands/push.rb +15 -0
- data/lib/aspen/cli/commands/version.rb +15 -0
- data/lib/aspen/cli/commands/watch.rb +30 -0
- data/lib/aspen/cli/commands.rb +28 -0
- data/lib/aspen/cli/templates/.gitignore +6 -0
- data/lib/aspen/cli/templates/airtable.yml +1 -0
- data/lib/aspen/cli/templates/convert +16 -0
- data/lib/aspen/cli/templates/db.yml.erb +22 -0
- data/lib/aspen/cli/templates/docker-compose.yml +23 -0
- data/lib/aspen/cli/templates/manifest.yml.erb +31 -0
- data/lib/aspen/cli.rb +9 -0
- data/lib/aspen/compiler.rb +209 -0
- data/lib/aspen/contracts/default_attribute_contract.rb +29 -0
- data/lib/aspen/contracts.rb +1 -0
- data/lib/aspen/conversion.rb +43 -0
- data/lib/aspen/custom_grammar/ast/nodes/bare.rb +17 -0
- data/lib/aspen/custom_grammar/ast/nodes/capture_segment.rb +19 -0
- data/lib/aspen/custom_grammar/ast/nodes/content.rb +17 -0
- data/lib/aspen/custom_grammar/ast/nodes/expression.rb +17 -0
- data/lib/aspen/custom_grammar/ast.rb +13 -0
- data/lib/aspen/custom_grammar/compiler.rb +80 -0
- data/lib/aspen/custom_grammar/grammar.rb +78 -0
- data/lib/aspen/custom_grammar/lexer.rb +76 -0
- data/lib/aspen/custom_grammar/matcher.rb +43 -0
- data/lib/aspen/custom_grammar/parser.rb +51 -0
- data/lib/aspen/custom_grammar.rb +23 -0
- data/lib/aspen/custom_statement.rb +35 -0
- data/lib/aspen/discourse.rb +122 -0
- data/lib/aspen/edge.rb +35 -0
- data/lib/aspen/errors.rb +158 -0
- data/lib/aspen/helpers.rb +17 -0
- data/lib/aspen/lexer.rb +195 -0
- data/lib/aspen/list.rb +19 -0
- data/lib/aspen/node.rb +53 -0
- data/lib/aspen/parser.rb +183 -0
- data/lib/aspen/renderers/abstract_renderer.rb +22 -0
- data/lib/aspen/renderers/cypher_base_renderer.rb +36 -0
- data/lib/aspen/renderers/cypher_batch_renderer.rb +55 -0
- data/lib/aspen/renderers/cypher_renderer.rb +18 -0
- data/lib/aspen/renderers/gexf_renderer.rb +47 -0
- data/lib/aspen/renderers/json_renderer.rb +40 -0
- data/lib/aspen/renderers.rb +9 -0
- data/lib/aspen/schemas/discourse_schema.rb +64 -0
- data/lib/aspen/schemas/grammar_schema.rb +24 -0
- data/lib/aspen/statement.rb +42 -0
- data/lib/aspen/system_default.rb +12 -0
- data/lib/aspen/version.rb +3 -0
- data/lib/aspen.rb +65 -0
- metadata +300 -0
data/lib/aspen/errors.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
module Aspen
|
|
2
|
+
|
|
3
|
+
class Error < StandardError ; end
|
|
4
|
+
|
|
5
|
+
class ArgumentError < Error ; end
|
|
6
|
+
|
|
7
|
+
class LexError < Error ; end
|
|
8
|
+
class ParseError < Error ; end
|
|
9
|
+
class CompileError < Error ; end
|
|
10
|
+
class MatchError < Error ; end
|
|
11
|
+
|
|
12
|
+
class AttributeCollisionError < Error ; end
|
|
13
|
+
|
|
14
|
+
class Errors
|
|
15
|
+
def self.messages(lookup, *args)
|
|
16
|
+
_messages[lookup].call(args)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def self._messages
|
|
22
|
+
{
|
|
23
|
+
unexpected_token: -> (args) {
|
|
24
|
+
<<~ERROR
|
|
25
|
+
Within state :#{args.last}, unexpected token "#{args.first.peek(1)}" at position #{args.first.pos}.
|
|
26
|
+
Next 30 characters: #{args.first.peek(30).inspect}
|
|
27
|
+
ERROR
|
|
28
|
+
},
|
|
29
|
+
default_already_registered: -> (args) {
|
|
30
|
+
<<~ERROR
|
|
31
|
+
You have already set a default label and attribute name for unlabeled nodes.
|
|
32
|
+
# TODO List them
|
|
33
|
+
|
|
34
|
+
Your configuration is trying to set a second set:
|
|
35
|
+
# TODO List them
|
|
36
|
+
|
|
37
|
+
Please edit your configuration so it only has one `default` line. You may, however,
|
|
38
|
+
use multiple `default_attribute` lines to set defaults for a specific label.
|
|
39
|
+
ERROR
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
bad_keyword: -> (args) {
|
|
43
|
+
<<~ERROR
|
|
44
|
+
Your configuration includes a line that starts with "#{args.first}".
|
|
45
|
+
This is not a valid configuration option.
|
|
46
|
+
ERROR
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
expected_match_precedent: -> (args) {
|
|
50
|
+
<<~ERROR
|
|
51
|
+
Indented two lines, so expected the last line to be either
|
|
52
|
+
:MATCH_START or :MATCH_TO, but was: #{args.first}.
|
|
53
|
+
ERROR
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
need_default_attribute: -> (args) {
|
|
57
|
+
<<~ERROR
|
|
58
|
+
I don't know what attribute is supposed to be assigned by default
|
|
59
|
+
to any node with the label `#{args.first}`.
|
|
60
|
+
|
|
61
|
+
To fix this, use `default_attribute`. For example, if the default
|
|
62
|
+
attribute should be the #{args.first}'s name, write this:
|
|
63
|
+
|
|
64
|
+
default_attribute #{args.first}, name
|
|
65
|
+
|
|
66
|
+
ERROR
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
no_default_line: -> (args) {
|
|
70
|
+
<<~ERROR
|
|
71
|
+
Nothing has been registered as the default node label. Please add
|
|
72
|
+
a line that indicates which label and attribute name should be applied
|
|
73
|
+
to unlabeled nodes.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
|
|
77
|
+
default Person, name
|
|
78
|
+
|
|
79
|
+
ERROR
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
no_body_tag: -> (args) {
|
|
83
|
+
<<~ERROR
|
|
84
|
+
We couldn't find a match for the following line
|
|
85
|
+
|
|
86
|
+
#{args.first}
|
|
87
|
+
|
|
88
|
+
among the following patterns
|
|
89
|
+
|
|
90
|
+
#{args.last.registry.map(&:pattern).map(&:inspect).join("\n")}
|
|
91
|
+
|
|
92
|
+
Every line should either match a custom grammar definition, or
|
|
93
|
+
start with a node, like:
|
|
94
|
+
|
|
95
|
+
(Matt) [knows] (Brianna).
|
|
96
|
+
|
|
97
|
+
ERROR
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
no_config_tag: -> (args) {
|
|
101
|
+
<<~ERROR
|
|
102
|
+
There's no configuration option that matches the line:
|
|
103
|
+
|
|
104
|
+
#{args.first}
|
|
105
|
+
|
|
106
|
+
Maybe it's a spelling error?
|
|
107
|
+
|
|
108
|
+
ERROR
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
no_grammar_match: -> (args) {
|
|
112
|
+
<<~ERROR
|
|
113
|
+
Expected pattern:
|
|
114
|
+
|
|
115
|
+
#{pattern}
|
|
116
|
+
|
|
117
|
+
to match
|
|
118
|
+
|
|
119
|
+
#{str}
|
|
120
|
+
ERROR
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
no_statement_tag: -> (args) {
|
|
124
|
+
<<~ERROR
|
|
125
|
+
Couldn't figure out how to tag '#{args.first}'."
|
|
126
|
+
ERROR
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
statement_node_count: -> (args) {
|
|
130
|
+
<<~ERROR
|
|
131
|
+
A statement must have exactly two nodes, but we found #{args.first.count} in this statement:
|
|
132
|
+
|
|
133
|
+
#{args.last}
|
|
134
|
+
|
|
135
|
+
The nodes are:
|
|
136
|
+
|
|
137
|
+
#{args.first.map(&:last).join(", ").inspect}
|
|
138
|
+
|
|
139
|
+
ERROR
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
statement_edge_count: -> (args) {
|
|
143
|
+
<<~ERROR
|
|
144
|
+
A statement must have exactly one edge, but we found #{args.first.count} in this statement:
|
|
145
|
+
|
|
146
|
+
#{args.last}
|
|
147
|
+
|
|
148
|
+
The edges are:
|
|
149
|
+
|
|
150
|
+
#{args.first.map(&:last).join(", ").inspect}
|
|
151
|
+
|
|
152
|
+
ERROR
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/aspen/lexer.rb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
require 'strscan'
|
|
2
|
+
|
|
3
|
+
module Aspen
|
|
4
|
+
class Lexer
|
|
5
|
+
|
|
6
|
+
STRING_CAPTURE = /(["'])(?:(?=(\\?))\2.)*?\1/
|
|
7
|
+
# From https://stackoverflow.com/questions/171480/regex-grabbing-values-between-quotation-marks
|
|
8
|
+
|
|
9
|
+
NUMBER_CAPTURE = /([\d,]+\.?\d+)/
|
|
10
|
+
|
|
11
|
+
PASCAL_CASE = /^([A-Z][a-zA-Z0-9]+)/
|
|
12
|
+
LABEL_PASCAL_CASE = /^(:[A-Z][a-zA-Z0-9]+)/
|
|
13
|
+
|
|
14
|
+
CONTENT_REGEX = /[[[:alnum:]][[:blank:]]"'&_,\-\–\—\!\.\/\\\?\$]+/
|
|
15
|
+
LABELED_NODE = /^([A-Z][a-zA-Z0-9]+): (#{CONTENT_REGEX})\)/
|
|
16
|
+
|
|
17
|
+
def self.tokenize(code, env={})
|
|
18
|
+
new.tokenize(code, env)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tokenize(code, env={})
|
|
22
|
+
scanner = StringScanner.new(code)
|
|
23
|
+
tokens = []
|
|
24
|
+
|
|
25
|
+
environment = Discourse.assert(env)
|
|
26
|
+
grammar = environment.grammar
|
|
27
|
+
|
|
28
|
+
until scanner.eos?
|
|
29
|
+
# puts "tokens (6): #{tokens.last(6).inspect}"
|
|
30
|
+
# puts "\n(#{state}) stack: #{stack}"
|
|
31
|
+
# puts "grammar: #{grammar.inspect}"
|
|
32
|
+
|
|
33
|
+
# Match custom grammars
|
|
34
|
+
if grammar && scanner.beginning_of_line?
|
|
35
|
+
line = scanner.scan(/^.*$/)
|
|
36
|
+
if grammar.match?(line)
|
|
37
|
+
tokens << [:CUSTOM_GRAMMAR_STATEMENT, line]
|
|
38
|
+
next
|
|
39
|
+
else
|
|
40
|
+
scanner.unscan # reset pointer to beginning of line
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# If the line ends with :, it's starting a list.
|
|
45
|
+
if scanner.beginning_of_line?
|
|
46
|
+
line = scanner.scan(/^.*$/)
|
|
47
|
+
if line.match? /:$/
|
|
48
|
+
tokens << [:PREPARE_START_LIST]
|
|
49
|
+
end
|
|
50
|
+
scanner.unscan
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Standard Aspen syntax
|
|
54
|
+
case state
|
|
55
|
+
when :default then
|
|
56
|
+
if scanner.scan(/\(/)
|
|
57
|
+
tokens << [:OPEN_PARENS]
|
|
58
|
+
push_state :node
|
|
59
|
+
elsif scanner.scan(/\[/)
|
|
60
|
+
tokens << [:OPEN_BRACKETS]
|
|
61
|
+
push_state :edge
|
|
62
|
+
elsif scanner.scan(/(:\s*\n)/) # Colon, any whitespace, newline
|
|
63
|
+
tokens << [:START_LIST, scanner.matched]
|
|
64
|
+
push_state :list
|
|
65
|
+
elsif scanner.scan(/\./)
|
|
66
|
+
tokens << [:END_STATEMENT, scanner.matched]
|
|
67
|
+
elsif scanner.scan(/\s/)
|
|
68
|
+
# NO OP
|
|
69
|
+
elsif scanner.scan(/#.*$/)
|
|
70
|
+
tokens << [:COMMENT, scanner.matched.gsub(/#\s*/, '')]
|
|
71
|
+
else
|
|
72
|
+
no_match(scanner, state)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
when :node then
|
|
76
|
+
# Removed Cypher form for now. Un comment the next 3 lines
|
|
77
|
+
# to start working on it.
|
|
78
|
+
#
|
|
79
|
+
# if scanner.scan(LABEL_PASCAL_CASE)
|
|
80
|
+
# tokens << [:LABEL, scanner.matched]
|
|
81
|
+
# push_state :hash
|
|
82
|
+
if scanner.match?(LABELED_NODE)
|
|
83
|
+
push_state :node_labeled
|
|
84
|
+
elsif scanner.scan(/\n/) && stack == [:list, :node]
|
|
85
|
+
# If we're inside a list node and we encounter a newline,
|
|
86
|
+
# pop :node so we can return to the :list state.
|
|
87
|
+
scanner.unscan
|
|
88
|
+
pop_state
|
|
89
|
+
elsif scanner.scan(CONTENT_REGEX)
|
|
90
|
+
tokens << [:CONTENT, scanner.matched.strip]
|
|
91
|
+
elsif scanner.scan(/[\:]/)
|
|
92
|
+
tokens << [:SEPARATOR, scanner.matched]
|
|
93
|
+
elsif scanner.scan(/\(/)
|
|
94
|
+
tokens << [:OPEN_PARENS]
|
|
95
|
+
push_state :label
|
|
96
|
+
elsif scanner.scan(/\)/)
|
|
97
|
+
tokens << [:CLOSE_PARENS]
|
|
98
|
+
pop_state
|
|
99
|
+
else
|
|
100
|
+
no_match(scanner, state)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
when :node_labeled
|
|
104
|
+
if scanner.scan(PASCAL_CASE)
|
|
105
|
+
tokens << [:LABEL, scanner.matched]
|
|
106
|
+
pop_state # Back to Node
|
|
107
|
+
else
|
|
108
|
+
no_match(scanner, state)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
when :edge then
|
|
112
|
+
if scanner.scan(/[[[:alpha:]]\s]+/)
|
|
113
|
+
tokens << [:CONTENT, scanner.matched.strip]
|
|
114
|
+
elsif scanner.scan(/\]/)
|
|
115
|
+
tokens << [:CLOSE_BRACKETS]
|
|
116
|
+
pop_state
|
|
117
|
+
else
|
|
118
|
+
no_match(scanner, state)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
when :hash then
|
|
122
|
+
if scanner.scan(/\{/)
|
|
123
|
+
tokens << [:OPEN_BRACES]
|
|
124
|
+
elsif scanner.scan(/[[[:alpha:]]_]+/)
|
|
125
|
+
tokens << [:IDENTIFIER, scanner.matched]
|
|
126
|
+
elsif scanner.scan(/[\,\:]/)
|
|
127
|
+
tokens << [:SEPARATOR, scanner.matched]
|
|
128
|
+
elsif scanner.scan(STRING_CAPTURE)
|
|
129
|
+
tokens << [:STRING, scanner.matched]
|
|
130
|
+
elsif scanner.scan(NUMBER_CAPTURE)
|
|
131
|
+
tokens << [:NUMBER, scanner.matched]
|
|
132
|
+
elsif scanner.scan(/\}/)
|
|
133
|
+
tokens << [:CLOSE_BRACES]
|
|
134
|
+
pop_state
|
|
135
|
+
elsif scanner.scan(/\s+/)
|
|
136
|
+
# NO OP
|
|
137
|
+
else
|
|
138
|
+
no_match(scanner, state)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
when :list then
|
|
142
|
+
if scanner.scan(/([\-\*\+])/) # -, *, or + (any allowed by Markdown)
|
|
143
|
+
tokens << [:BULLET, scanner.matched]
|
|
144
|
+
push_state :node
|
|
145
|
+
elsif scanner.scan(/\n\n/)
|
|
146
|
+
tokens << [:END_LIST]
|
|
147
|
+
pop_state
|
|
148
|
+
elsif scanner.scan(/\s/)
|
|
149
|
+
# NO OP
|
|
150
|
+
else
|
|
151
|
+
no_match(scanner, state)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
when :label
|
|
155
|
+
if scanner.scan(PASCAL_CASE)
|
|
156
|
+
tokens << [:CONTENT, scanner.matched]
|
|
157
|
+
elsif scanner.peek(1).match?(/\)/)
|
|
158
|
+
pop_state # Go back to :node and let :node pop state
|
|
159
|
+
else
|
|
160
|
+
no_match(scanner, state)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
else # No state match
|
|
164
|
+
raise Aspen::LexError, "There is no matcher for state #{state.inspect}."
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
tokens
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def stack
|
|
172
|
+
@stack ||= []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def state
|
|
176
|
+
stack.last || :default
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def push_state(state)
|
|
180
|
+
stack.push(state)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def pop_state
|
|
184
|
+
stack.pop
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def no_match(scanner, state)
|
|
190
|
+
raise Aspen::LexError,
|
|
191
|
+
Aspen::Errors.messages(:unexpected_token, scanner, state)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/aspen/list.rb
ADDED
data/lib/aspen/node.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require 'active_support/core_ext/string/inflections'
|
|
2
|
+
require 'aspen/statement'
|
|
3
|
+
|
|
4
|
+
module Aspen
|
|
5
|
+
class Node
|
|
6
|
+
|
|
7
|
+
extend Dry::Monads[:maybe]
|
|
8
|
+
|
|
9
|
+
attr_reader :label, :attributes, :nickname
|
|
10
|
+
attr_writer :nickname
|
|
11
|
+
|
|
12
|
+
def initialize(label: , attributes: {})
|
|
13
|
+
@label = label
|
|
14
|
+
@attributes = attributes
|
|
15
|
+
@nickname = nickname_from_first_attr_value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def nickname_from_first_attr_value
|
|
19
|
+
"#{@label}-#{@attributes.values.first}".parameterize.underscore
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_cypher
|
|
23
|
+
if nickname
|
|
24
|
+
"(#{nickname}:#{label} #{ attribute_string })"
|
|
25
|
+
else
|
|
26
|
+
"(#{label} #{ attribute_string })"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def nickname_node
|
|
31
|
+
"(#{nickname})"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def signature
|
|
35
|
+
"(#{label})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def attribute_string
|
|
39
|
+
attributes.to_s.
|
|
40
|
+
gsub(/"(?<token>[[:alpha:]_]+)"=>/, '\k<token>: ').
|
|
41
|
+
# This puts a single space inside curly braces.
|
|
42
|
+
gsub(/\{(\s*)/, "{ ").
|
|
43
|
+
gsub(/(\s*)\}/, " }")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ==(other)
|
|
47
|
+
label == other.label &&
|
|
48
|
+
attributes == other.attributes &&
|
|
49
|
+
nickname == other.nickname
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/aspen/parser.rb
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require 'active_support/inflector'
|
|
2
|
+
|
|
3
|
+
module Aspen
|
|
4
|
+
class Parser < AbstractParser
|
|
5
|
+
|
|
6
|
+
=begin
|
|
7
|
+
|
|
8
|
+
narrative = statements;
|
|
9
|
+
statements = { statement }
|
|
10
|
+
statement = COMMENT | CUSTOM_STATEMENT | list_statement | vanilla_statement
|
|
11
|
+
|
|
12
|
+
# Variant 1. TODO: Variant 2
|
|
13
|
+
list_statement = node, edge, [ list_label ], START_LIST, list_items, END_LIST
|
|
14
|
+
list_label = OPEN_PARENS, LABEL, CLOSE_PARENS
|
|
15
|
+
list_items = { list_item }
|
|
16
|
+
list_item = BULLET, CONTENT, [ list_item_label ]
|
|
17
|
+
list_item_label = OPEN_PARENS, LABEL, CLOSE_PARENS
|
|
18
|
+
vanilla_statement = node | node, edge, node, { END_STATEMENT }
|
|
19
|
+
node = node_short_form | node_grouped_form | node_cypher_form
|
|
20
|
+
node_short_form = OPEN_PARENS, CONTENT, CLOSE_PARENS
|
|
21
|
+
node_grouped_form = OPEN_PARENS, { CONTENT, [ COMMA ] }, CLOSE_PARENS
|
|
22
|
+
node_cypher_form = OPEN_PARENS, LABEL, OPEN_BRACES, { IDENTIFIER, literal, [ COMMA ] }, CLOSE_BRACES
|
|
23
|
+
literal = STRING | NUMBER
|
|
24
|
+
edge = OPEN_BRACKETS, CONTENT, CLOSE_BRACKETS
|
|
25
|
+
|
|
26
|
+
=end
|
|
27
|
+
|
|
28
|
+
def parse
|
|
29
|
+
Aspen::AST::Nodes::Narrative.new(parse_statements)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
alias_method :parse_narrative, :parse
|
|
33
|
+
|
|
34
|
+
def parse_statements
|
|
35
|
+
results = []
|
|
36
|
+
|
|
37
|
+
# Make sure this returns on empty
|
|
38
|
+
while result = parse_statement
|
|
39
|
+
results.push(*result)
|
|
40
|
+
break if tokens[position].nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_statement
|
|
47
|
+
parse_comment ||
|
|
48
|
+
parse_custom_statement ||
|
|
49
|
+
parse_list_statement ||
|
|
50
|
+
parse_vanilla_statement
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parse_comment
|
|
54
|
+
if comment = expect(:COMMENT)
|
|
55
|
+
Aspen::AST::Nodes::Comment.new(comment.first.last)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_custom_statement
|
|
60
|
+
if content = expect(:CUSTOM_GRAMMAR_STATEMENT)
|
|
61
|
+
# FIXME: Why does this need a #first and a #last?
|
|
62
|
+
# Seems unnecessarily nested. Maybe this happened in the lexer.
|
|
63
|
+
Aspen::AST::Nodes::CustomStatement.new(content.first.last)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_list_statement
|
|
68
|
+
if expect(:PREPARE_START_LIST)
|
|
69
|
+
origin = parse_node
|
|
70
|
+
edge = parse_edge
|
|
71
|
+
label = parse_list_label
|
|
72
|
+
targets = parse_list_items
|
|
73
|
+
expect(:END_LIST)
|
|
74
|
+
targets.map do |target|
|
|
75
|
+
# puts "TARGET #{target.attribute.content.inner_content} had label #{target.label.content.inner_content.inspect}"
|
|
76
|
+
target.label = label if target.label.content.inner_content.nil?
|
|
77
|
+
# puts "TARGET #{target.attribute.content.inner_content} has label #{target.label.content.inner_content.inspect}"
|
|
78
|
+
Aspen::AST::Nodes::Statement.new(origin: origin, edge: edge, target: target)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_list_label
|
|
84
|
+
if (_, plural_label, _ = expect(:OPEN_PARENS, :CONTENT, :CLOSE_PARENS))
|
|
85
|
+
# If singularizing should be conditional, we need to introduce the env in the parser.
|
|
86
|
+
return plural_label.last.singularize
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_list_items
|
|
91
|
+
if need(:START_LIST)
|
|
92
|
+
results = []
|
|
93
|
+
while target = parse_list_item
|
|
94
|
+
results << target
|
|
95
|
+
end
|
|
96
|
+
results
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_list_item
|
|
101
|
+
node = nil
|
|
102
|
+
if (_, content = expect(:BULLET, :CONTENT))
|
|
103
|
+
node = Aspen::AST::Nodes::Node.new(attribute: content.last)
|
|
104
|
+
end
|
|
105
|
+
if (label = parse_list_item_label)
|
|
106
|
+
node.label = label
|
|
107
|
+
end
|
|
108
|
+
return node
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_list_item_label
|
|
112
|
+
if (_, item_label, _ = expect(:OPEN_PARENS, :CONTENT, :CLOSE_PARENS))
|
|
113
|
+
# If singularizing should be conditional, we need to introduce the env in the parser.
|
|
114
|
+
return item_label.last
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_node_labeled_form
|
|
120
|
+
raise NotImplementedError, "#parse_node_labeled_form not yet implemented"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_vanilla_statement
|
|
124
|
+
# TODO: Might benefit from a condition when doing non-vanilla statements?
|
|
125
|
+
origin = parse_node
|
|
126
|
+
edge = parse_edge
|
|
127
|
+
target = parse_node
|
|
128
|
+
|
|
129
|
+
# SMELL: Nil check
|
|
130
|
+
advance if peek && peek.first == :END_STATEMENT
|
|
131
|
+
|
|
132
|
+
Aspen::AST::Nodes::Statement.new(origin: origin, edge: edge, target: target)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_node
|
|
136
|
+
# parse_node_cypher_form ||
|
|
137
|
+
parse_node_grouped_form || parse_node_short_form
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def parse_node_grouped_form
|
|
141
|
+
if (_, label, sep, content, _ = expect(:OPEN_PARENS, :LABEL, :SEPARATOR, :CONTENT, :CLOSE_PARENS))
|
|
142
|
+
Aspen::AST::Nodes::Node.new(
|
|
143
|
+
attribute: content.last,
|
|
144
|
+
label: label.last
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_node_short_form
|
|
150
|
+
# Terminal instructions require a "need"
|
|
151
|
+
_, content, _ = need(:OPEN_PARENS, :CONTENT, :CLOSE_PARENS)
|
|
152
|
+
Aspen::AST::Nodes::Node.new(
|
|
153
|
+
attribute: content.last,
|
|
154
|
+
label: nil
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# This complicates things greatly. Can we skip this for now,
|
|
159
|
+
# by rewriting the tests to get rid of this case, and come back to it?
|
|
160
|
+
def parse_node_cypher_form
|
|
161
|
+
if (_, label, _, content, _ = expect(:OPEN_PARENS, :CONTENT, :SEPARATOR, :CONTENT, :CLOSE_PARENS))
|
|
162
|
+
Aspen::AST::Nodes::Node.new(content: content.last, label: label.last)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_literal
|
|
167
|
+
raise NotImplementedError, "#parse_literal not yet implemented"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_edge
|
|
171
|
+
if (_, content, _ = expect(:OPEN_BRACKETS, :CONTENT, :CLOSE_BRACKETS))
|
|
172
|
+
Aspen::AST::Nodes::Edge.new(content.last)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# private
|
|
177
|
+
|
|
178
|
+
# def guard(condition)
|
|
179
|
+
# return false unless condition
|
|
180
|
+
# end
|
|
181
|
+
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class AbstractRenderer
|
|
2
|
+
|
|
3
|
+
attr_reader :statements, :environment
|
|
4
|
+
|
|
5
|
+
def initialize(statements, environment = {})
|
|
6
|
+
@statements = statements
|
|
7
|
+
@environment = environment
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
raise NotImplementedError, "Find me in #{__FILE__}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def nodes
|
|
15
|
+
raise NotImplementedError, "Find me in #{__FILE__}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def relationships
|
|
19
|
+
raise NotImplementedError, "Find me in #{__FILE__}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Aspen
|
|
2
|
+
module Renderers
|
|
3
|
+
class CypherBaseRenderer < AbstractRenderer
|
|
4
|
+
|
|
5
|
+
def render
|
|
6
|
+
[
|
|
7
|
+
nodes(statements),
|
|
8
|
+
"\n\n",
|
|
9
|
+
relationships(statements),
|
|
10
|
+
"\n"
|
|
11
|
+
].join()
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def nodes(input_statements)
|
|
15
|
+
input_statements.
|
|
16
|
+
flat_map(&:nodes).
|
|
17
|
+
map { |node| "MERGE #{node.to_cypher}" }.
|
|
18
|
+
uniq.
|
|
19
|
+
join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def relationships(input_statements)
|
|
23
|
+
input_statements.map do |statement|
|
|
24
|
+
if statement.type == :custom
|
|
25
|
+
statement.to_cypher.lines.map { |line| "MERGE #{line}" }.join()
|
|
26
|
+
elsif statement.type == :vanilla
|
|
27
|
+
"MERGE #{statement.to_cypher}"
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "Statement is the wrong type, expected Aspen::CustomStatemen or Aspen::Statement, but got #{statement.class}"
|
|
30
|
+
end
|
|
31
|
+
end.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|