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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/.github/FUNDING.yml +12 -0
  3. data/.gitignore +16 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/Gemfile.lock +175 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +84 -0
  10. data/Rakefile +10 -0
  11. data/aspen.gemspec +48 -0
  12. data/bin/aspen +3 -0
  13. data/bin/console +22 -0
  14. data/bin/setup +8 -0
  15. data/lib/aspen/abstract_parser.rb +72 -0
  16. data/lib/aspen/abstract_statement.rb +34 -0
  17. data/lib/aspen/actions/compile.rb +31 -0
  18. data/lib/aspen/actions/push.rb +39 -0
  19. data/lib/aspen/actions/watch.rb +55 -0
  20. data/lib/aspen/actions.rb +8 -0
  21. data/lib/aspen/adapters.rb +38 -0
  22. data/lib/aspen/ast/nodes/attribute.rb +16 -0
  23. data/lib/aspen/ast/nodes/comment.rb +15 -0
  24. data/lib/aspen/ast/nodes/content.rb +17 -0
  25. data/lib/aspen/ast/nodes/custom_statement.rb +19 -0
  26. data/lib/aspen/ast/nodes/edge.rb +15 -0
  27. data/lib/aspen/ast/nodes/label.rb +15 -0
  28. data/lib/aspen/ast/nodes/narrative.rb +15 -0
  29. data/lib/aspen/ast/nodes/node.rb +20 -0
  30. data/lib/aspen/ast/nodes/statement.rb +17 -0
  31. data/lib/aspen/ast/nodes/type.rb +46 -0
  32. data/lib/aspen/ast.rb +18 -0
  33. data/lib/aspen/cli/commands/build.rb +26 -0
  34. data/lib/aspen/cli/commands/build_steps.rb +204 -0
  35. data/lib/aspen/cli/commands/compile.rb +27 -0
  36. data/lib/aspen/cli/commands/generate.rb +23 -0
  37. data/lib/aspen/cli/commands/new.rb +115 -0
  38. data/lib/aspen/cli/commands/push.rb +15 -0
  39. data/lib/aspen/cli/commands/version.rb +15 -0
  40. data/lib/aspen/cli/commands/watch.rb +30 -0
  41. data/lib/aspen/cli/commands.rb +28 -0
  42. data/lib/aspen/cli/templates/.gitignore +6 -0
  43. data/lib/aspen/cli/templates/airtable.yml +1 -0
  44. data/lib/aspen/cli/templates/convert +16 -0
  45. data/lib/aspen/cli/templates/db.yml.erb +22 -0
  46. data/lib/aspen/cli/templates/docker-compose.yml +23 -0
  47. data/lib/aspen/cli/templates/manifest.yml.erb +31 -0
  48. data/lib/aspen/cli.rb +9 -0
  49. data/lib/aspen/compiler.rb +209 -0
  50. data/lib/aspen/contracts/default_attribute_contract.rb +29 -0
  51. data/lib/aspen/contracts.rb +1 -0
  52. data/lib/aspen/conversion.rb +43 -0
  53. data/lib/aspen/custom_grammar/ast/nodes/bare.rb +17 -0
  54. data/lib/aspen/custom_grammar/ast/nodes/capture_segment.rb +19 -0
  55. data/lib/aspen/custom_grammar/ast/nodes/content.rb +17 -0
  56. data/lib/aspen/custom_grammar/ast/nodes/expression.rb +17 -0
  57. data/lib/aspen/custom_grammar/ast.rb +13 -0
  58. data/lib/aspen/custom_grammar/compiler.rb +80 -0
  59. data/lib/aspen/custom_grammar/grammar.rb +78 -0
  60. data/lib/aspen/custom_grammar/lexer.rb +76 -0
  61. data/lib/aspen/custom_grammar/matcher.rb +43 -0
  62. data/lib/aspen/custom_grammar/parser.rb +51 -0
  63. data/lib/aspen/custom_grammar.rb +23 -0
  64. data/lib/aspen/custom_statement.rb +35 -0
  65. data/lib/aspen/discourse.rb +122 -0
  66. data/lib/aspen/edge.rb +35 -0
  67. data/lib/aspen/errors.rb +158 -0
  68. data/lib/aspen/helpers.rb +17 -0
  69. data/lib/aspen/lexer.rb +195 -0
  70. data/lib/aspen/list.rb +19 -0
  71. data/lib/aspen/node.rb +53 -0
  72. data/lib/aspen/parser.rb +183 -0
  73. data/lib/aspen/renderers/abstract_renderer.rb +22 -0
  74. data/lib/aspen/renderers/cypher_base_renderer.rb +36 -0
  75. data/lib/aspen/renderers/cypher_batch_renderer.rb +55 -0
  76. data/lib/aspen/renderers/cypher_renderer.rb +18 -0
  77. data/lib/aspen/renderers/gexf_renderer.rb +47 -0
  78. data/lib/aspen/renderers/json_renderer.rb +40 -0
  79. data/lib/aspen/renderers.rb +9 -0
  80. data/lib/aspen/schemas/discourse_schema.rb +64 -0
  81. data/lib/aspen/schemas/grammar_schema.rb +24 -0
  82. data/lib/aspen/statement.rb +42 -0
  83. data/lib/aspen/system_default.rb +12 -0
  84. data/lib/aspen/version.rb +3 -0
  85. data/lib/aspen.rb +65 -0
  86. metadata +300 -0
@@ -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
@@ -0,0 +1,17 @@
1
+ module Aspen
2
+ module Helpers
3
+ def node(*args)
4
+ if args.first.is_a? Hash
5
+ h = args.first
6
+ "(#{h.keys.first}: #{h.values.first})"
7
+ else
8
+ "not hash"
9
+ "(#{args.first})"
10
+ end
11
+ end
12
+
13
+ def edge(name)
14
+ "[#{name}]"
15
+ end
16
+ end
17
+ end
@@ -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
@@ -0,0 +1,19 @@
1
+ module Aspen
2
+ class List
3
+
4
+ REGEX = /\s*,?\s*and\s*/i
5
+
6
+ def initialize(array = [])
7
+ @elements = Array(array)
8
+ end
9
+
10
+ def self.from_text(text)
11
+ new text.
12
+ gsub(REGEX, ', ').
13
+ split(',').
14
+ map(&:strip)
15
+
16
+ end
17
+ end
18
+ end
19
+
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
@@ -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