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,13 @@
1
+ module Aspen
2
+ module CustomGrammar
3
+ module AST
4
+ module Nodes
5
+ end
6
+ end
7
+ end
8
+ end
9
+
10
+ require 'aspen/custom_grammar/ast/nodes/bare'
11
+ require 'aspen/custom_grammar/ast/nodes/capture_segment'
12
+ require 'aspen/custom_grammar/ast/nodes/content'
13
+ require 'aspen/custom_grammar/ast/nodes/expression'
@@ -0,0 +1,80 @@
1
+ module Aspen
2
+ module CustomGrammar
3
+ class Compiler
4
+
5
+ attr_reader :root, :environment
6
+
7
+ def self.render(root, environment = {})
8
+ new(root, environment).render
9
+ end
10
+
11
+ def self.compile(root, environment = {})
12
+ new(root, environment).compile
13
+ end
14
+
15
+ def initialize(root, environment = {})
16
+ @root = root
17
+ @environment = environment
18
+ @type_registry = {}
19
+ @label_registry = {}
20
+ end
21
+
22
+ def compile
23
+ # Call #render before accessing the registry. If this code
24
+ # changes, we may need more process control to ensure #render
25
+ # goes first.
26
+ {
27
+ pattern: render,
28
+ type_registry: @type_registry,
29
+ label_registry: @label_registry
30
+ }
31
+ end
32
+
33
+ def render
34
+ visit(root)
35
+ end
36
+
37
+ def visit(node)
38
+ short_name = node.class.to_s.split('::').last.downcase
39
+ method_name = "visit_#{short_name}"
40
+ # puts "---- #{method_name}"
41
+ # puts node.inspect
42
+ send(method_name, node)
43
+ end
44
+
45
+ def visit_expression(node)
46
+ segments = node.segments.map { |segment| visit(segment) }
47
+ segments.last.gsub!(/\.$/, '') # Make the last period optional? Maybe?
48
+ segments.unshift "^" # Add a bol matcher to the front.
49
+ segments.push "\\.?$" # Make the last period optional? Again?
50
+ Regexp.new(segments.join)
51
+ end
52
+
53
+ def visit_capturesegment(node)
54
+ value = case node.type
55
+ when :integer then /(?<#{node.var_name}>[\d,]+\d*)/ # No decimal
56
+ when :float then /(?<#{node.var_name}>[\d,]+\.\d+)/ # Decimal point required
57
+ when :numeric then /(?<#{node.var_name}>[\d,]+\.?\d*)/ # Optional decimal
58
+ when :string then /(?<#{node.var_name}>.*?)/
59
+ when :node then /(?<#{node.var_name}>.*?)/
60
+ else
61
+ raise ArgumentError, "No regexp pattern for type \"#{node.type}\"."
62
+ end
63
+ # Add type to type registry
64
+ @type_registry[node.var_name] = node.type
65
+ # Add label to label registry
66
+ @label_registry[node.var_name] = node.label if node.type == :node
67
+ return value
68
+ end
69
+
70
+ def visit_bare(node)
71
+ visit(node.content)
72
+ end
73
+
74
+ def visit_content(node)
75
+ node.content
76
+ end
77
+
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,78 @@
1
+ module Aspen
2
+ module CustomGrammar
3
+ class Grammar
4
+
5
+ # API Surface:
6
+ # #add?
7
+ # #match? (Boolean) -> Does the grammar cover this string, match this case?
8
+ # #match (Matcher) -> Request the matcher for this string.
9
+ # #compile_pattern (Regexp) -> Given a matcher expression, return the Regexp pattern that can match against a string.
10
+
11
+ attr_reader :registry
12
+
13
+ include Dry::Monads[:maybe]
14
+
15
+ def initialize()
16
+ @registry = []
17
+ @slug_counters = Hash.new { 1 }
18
+ end
19
+
20
+ # Does the given text match a matcher?
21
+ def match?(string)
22
+ !!match(string)
23
+ rescue Aspen::Error
24
+ false
25
+ end
26
+
27
+ def match(text)
28
+ results = @registry.select { |m| m.match?(text) }
29
+ warn "Found #{results.count} matches" if results.count > 1
30
+ # raise Aspen::Error, "No results." if results.empty?
31
+ return results.first
32
+ end
33
+
34
+ alias_method :matcher_for, :match
35
+
36
+ def inspect
37
+ "#<Aspen::Grammar matchers: #{count}>"
38
+ end
39
+
40
+ def count
41
+ registry.count
42
+ end
43
+
44
+ def add(maybe_matchers)
45
+ matchers = Array(maybe_matchers).flatten
46
+ raise unless matchers.all? { |m| m.is_a? Aspen::CustomGrammar::Matcher }
47
+ matchers.each { |matcher| @registry << matcher }
48
+ end
49
+
50
+ # This doesn't quite work, because var results is untyped.
51
+ def render(content)
52
+ matcher = matcher_for(content)
53
+ results = results_for(content)
54
+ template = matcher.template
55
+ end
56
+
57
+ def results_for(text)
58
+ matcher_for(text).captures!(text)
59
+ end
60
+
61
+ def match!(text)
62
+ unless match(text)
63
+ raise Aspen::MatchError, <<~ERROR
64
+ Couldn't find an Aspen grammar that matched the line:
65
+
66
+ #{text}
67
+
68
+ For more details (if you can), try running this to see all the match patterns:
69
+
70
+ Aspen::Grammar.registry.map(&:pattern)
71
+
72
+ ERROR
73
+ end
74
+ end
75
+
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,76 @@
1
+ require 'strscan'
2
+
3
+ module Aspen
4
+ module CustomGrammar
5
+ class Lexer
6
+ def self.tokenize(code, env={})
7
+ new.tokenize(code, env)
8
+ end
9
+
10
+ def tokenize(code, env={})
11
+ scanner = StringScanner.new(code)
12
+ tokens = []
13
+
14
+ # puts "tokens: #{tokens} | state: #{state} | stack: #{stack}"
15
+
16
+ until scanner.eos?
17
+ case state
18
+ when :default then
19
+ if scanner.scan(/\(/)
20
+ tokens << [:OPEN_PARENS]
21
+ push_state :capture_segment
22
+ elsif scanner.scan(/[[[:alnum:]][[:blank:]]\!"\#$%&'*+,\-.\/:;<=>?@\[\\\]^_‘\{\|\}~]+/)
23
+ tokens << [:BARE, scanner.matched]
24
+ else
25
+ no_match(scanner, state)
26
+ end
27
+
28
+ when :capture_segment
29
+ if scanner.scan(/\s+/)
30
+ # NO OP
31
+ elsif scanner.scan(/^(numeric|integer|float|string)/)
32
+ tokens << [:TYPE, scanner.matched]
33
+ elsif scanner.scan(Aspen::Lexer::PASCAL_CASE)
34
+ tokens << [:TYPE, ["node", scanner.matched]]
35
+ # TODO: This should only accept legal variable names, like `hello_01`
36
+ elsif scanner.scan(/^\w+/)
37
+ tokens << [:VAR_NAME, scanner.matched]
38
+ elsif scanner.scan(/\)/)
39
+ tokens << [:CLOSE_PARENS]
40
+ pop_state
41
+ else
42
+ no_match(scanner, state)
43
+ end
44
+
45
+ else
46
+ raise Aspen::LexError, "There is no matcher for state #{state.inspect}."
47
+ end
48
+ end
49
+
50
+ tokens
51
+ end
52
+
53
+ def stack
54
+ @stack ||= []
55
+ end
56
+
57
+ def state
58
+ stack.last || :default
59
+ end
60
+
61
+ def push_state(state)
62
+ stack.push(state)
63
+ end
64
+
65
+ def pop_state
66
+ stack.pop
67
+ end
68
+
69
+ def no_match(scanner, state)
70
+ raise Aspen::LexError,
71
+ Aspen::Errors.messages(:unexpected_token, scanner, state)
72
+ end
73
+
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,43 @@
1
+ require 'mustache'
2
+
3
+ module Aspen
4
+ module CustomGrammar
5
+ class Matcher
6
+
7
+ attr_accessor :expression, :template, :pattern, :typereg, :labelreg
8
+
9
+ def initialize(expression: , template: , pattern: )
10
+ @expression = expression
11
+ @template = template
12
+ # SMELL: I don't like this design.
13
+ compiled_grammar = Aspen::CustomGrammar.compile(expression)
14
+ @pattern = compiled_grammar[:pattern]
15
+ @typereg = compiled_grammar[:type_registry]
16
+ @labelreg = compiled_grammar[:label_registry]
17
+ end
18
+
19
+ def match?(str)
20
+ pattern.match?(str)
21
+ end
22
+
23
+ # Compare against narrative line to get captures
24
+ # Example results: { a: , amt: , b: }
25
+ def captures(str)
26
+ pattern.match(str).named_captures
27
+ end
28
+ alias_method :results, :captures
29
+
30
+ def captures!(str)
31
+ unless match?(str)
32
+ raise Aspen::MatchError,
33
+ Aspen::Errors.messages(:no_grammar_match, pattern, str)
34
+ end
35
+ captures(str)
36
+ end
37
+
38
+ alias_method :results!, :captures!
39
+
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ module Aspen
2
+ module CustomGrammar
3
+ class Parser < Aspen::AbstractParser
4
+
5
+ # expression = { segment }
6
+ # segment = BARE || capture_segment
7
+ # capture_segment = OPEN_PARENS, type, VAR_NAME, CLOSE_PARENS
8
+ # type = { node CONTENT } || numeric | float | integer | string
9
+
10
+ def parse
11
+ Aspen::CustomGrammar::AST::Nodes::Expression.new(parse_expression)
12
+ end
13
+
14
+ def parse_expression
15
+ segments = []
16
+
17
+ # Make sure this returns on empty
18
+ while segment = parse_segment
19
+ segments << segment
20
+ break if tokens[position].nil?
21
+ end
22
+
23
+ segments
24
+ end
25
+
26
+ def parse_segment
27
+ return parse_bare_segment || parse_capture_segment
28
+ raise Aspen::ParseError, "Didn't match expected tokens, got\n\t#{upcoming.inspect}"
29
+ end
30
+
31
+ def parse_bare_segment
32
+ if content = expect(:BARE)
33
+ Aspen::CustomGrammar::AST::Nodes::Bare.new(content.first.last)
34
+ end
35
+ end
36
+
37
+ def parse_capture_segment
38
+ if (_, type, var_name, _ = expect(:OPEN_PARENS, :TYPE, :VAR_NAME, :CLOSE_PARENS))
39
+ type_name, label = type.last
40
+
41
+ Aspen::CustomGrammar::AST::Nodes::CaptureSegment.new(
42
+ type: type_name.to_sym,
43
+ var_name: var_name.last,
44
+ label: label
45
+ )
46
+ end
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ require 'aspen/custom_grammar/ast'
2
+ require 'aspen/custom_grammar/lexer'
3
+ require 'aspen/custom_grammar/parser'
4
+ require 'aspen/custom_grammar/compiler'
5
+
6
+ require 'aspen/custom_grammar/matcher'
7
+ require 'aspen/custom_grammar/grammar'
8
+
9
+ module Aspen
10
+ module CustomGrammar
11
+
12
+ def self.compile(expression)
13
+ tokens = Aspen::CustomGrammar::Lexer.tokenize(expression)
14
+ ast = Aspen::CustomGrammar::Parser.parse(tokens)
15
+ Aspen::CustomGrammar::Compiler.compile(ast)
16
+ end
17
+
18
+ def self.compile_pattern(expression)
19
+ self.compile(expression)[:pattern]
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ require 'dry/types'
2
+
3
+ module Aspen
4
+ class CustomStatement < AbstractStatement
5
+
6
+ include Dry::Monads[:maybe]
7
+
8
+ attr_reader :nodes, :type
9
+
10
+ def type
11
+ :custom
12
+ end
13
+
14
+ # @todo The signature of the custom statement should be the
15
+ # Cypher template for that statement.
16
+ def signature
17
+ "custom"
18
+ end
19
+
20
+ # @param nodes [Array<Aspen::Node>] a list of nodes
21
+ # @param cypher [String] the Cypher generated by the template
22
+ # by the compiler
23
+ # @todo The inclusion of the Cypher parameter *suggests* that
24
+ # there might be a better way to generate the Cypher from a
25
+ # custom statement. Not sure.
26
+ def initialize(nodes: , cypher: )
27
+ @nodes = nodes
28
+ @cypher = cypher
29
+ end
30
+
31
+ def to_cypher
32
+ @cypher
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,122 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+ require 'dry/monads'
3
+ require 'yaml'
4
+
5
+ require 'aspen/schemas/discourse_schema'
6
+
7
+ module Aspen
8
+ class Discourse
9
+
10
+ include Dry::Monads[:maybe]
11
+
12
+ attr_reader :data, :grammar
13
+
14
+ def self.from_yaml(yaml)
15
+ from_hash YAML.load(yaml)
16
+ end
17
+
18
+ def self.from_hash(data = {})
19
+ raise ArgumentError, "Must be a hash, was a #{data.class}" unless data.is_a?(Hash)
20
+ result = Schemas::DiscourseSchema.call(data)
21
+ if result.success?
22
+ new(data)
23
+ else
24
+ # TODO: Improve this output for human readability
25
+ raise Aspen::Error, result.errors.messages.to_s
26
+ end
27
+ end
28
+
29
+ def self.assert(data)
30
+ return nil if data.nil?
31
+ case data.class.to_s
32
+ when "Aspen::Discourse" then data
33
+ when "Hash" then from_hash(data)
34
+ when "String" then from_yaml(data)
35
+ else
36
+ raise ArgumentError, "Must be a Hash, string (containing YAML), or Aspen::Discourse, got:\n\t#{data} (#{data.class})"
37
+ end
38
+ end
39
+
40
+ def initialize(data = {})
41
+ @data = data.with_indifferent_access
42
+ @grammar = Aspen::CustomGrammar::Grammar.new
43
+ process_grammar
44
+ end
45
+
46
+ def default_label
47
+ maybe_label = Maybe(@data.dig(:default, :label))
48
+ maybe_label.value_or(Aspen::SystemDefault.label)
49
+ end
50
+
51
+ def default_attr_name(label)
52
+ maybe_attr = Maybe(@data.dig(:default, :attributes, label.to_sym))
53
+ maybe_attr.value_or(primary_default_attr_name)
54
+ end
55
+
56
+ def allowed_labels
57
+ @al ||= whitelist_for(:nodes)
58
+ end
59
+
60
+ def allowed_edges
61
+ @ae ||= whitelist_for(:edges)
62
+ end
63
+
64
+ def allows_label?(label)
65
+ allowed_labels.empty? || allowed_labels.include?(label)
66
+ end
67
+
68
+ def allows_edge?(edge)
69
+ allowed_edges.empty? || allowed_edges.include?(edge)
70
+ end
71
+
72
+ def mutual
73
+ maybe_list = Maybe(@data.dig(:mutual) || @data.dig(:reciprocal))
74
+ maybe_list.value_or(Array.new)
75
+ end
76
+
77
+ def mutual?(edge_name)
78
+ mutual.include? edge_name
79
+ end
80
+
81
+ alias_method :reciprocal, :mutual
82
+ alias_method :reciprocal?, :mutual?
83
+
84
+ def add_grammar(grammar)
85
+ @grammar = grammar
86
+ end
87
+
88
+ private
89
+
90
+ def primary_default_attr_name
91
+ maybe_attr = Maybe(@data.dig(:default, :attribute))
92
+ maybe_attr.value_or(Aspen::SystemDefault.attr_name)
93
+ end
94
+
95
+ def whitelist_for(stuff)
96
+ maybe_whitelist = Maybe(@data.dig(:allow_only, stuff))
97
+ list = maybe_whitelist.value_or([])
98
+ return list if list.is_a? Array # If it's already a YAML list, great.
99
+ list.split(",").map(&:strip) # Otherwise, split the comma-separated string
100
+ end
101
+
102
+ # Converts multiple lines
103
+ def process_grammar
104
+ return false unless configured_grammar
105
+ configured_grammar.each do |block|
106
+ Array(block.fetch(:match)).each do |expression|
107
+ matcher = Aspen::CustomGrammar::Matcher.new(
108
+ expression: expression,
109
+ template: block.fetch(:template),
110
+ pattern: Aspen::CustomGrammar.compile_pattern(expression)
111
+ )
112
+ grammar.add(matcher)
113
+ end
114
+ end
115
+ end
116
+
117
+ def configured_grammar
118
+ @cg ||= Maybe(@data.dig(:grammar)).value_or(false)
119
+ end
120
+
121
+ end
122
+ end
data/lib/aspen/edge.rb ADDED
@@ -0,0 +1,35 @@
1
+ module Aspen
2
+ class Edge
3
+
4
+ # @todo Rename :word to :label
5
+ def initialize(name, mutual: false)
6
+ @name = name
7
+ @mutual = mutual
8
+ end
9
+
10
+ def label
11
+ @name
12
+ end
13
+
14
+ def to_cypher
15
+ "-[:#{label.parameterize.underscore.upcase}]-#{cap}"
16
+ end
17
+
18
+ def signature
19
+ to_cypher.gsub(/:/, '')
20
+ end
21
+
22
+ def mutual?
23
+ @mutual
24
+ end
25
+
26
+ alias_method :reciprocal?, :mutual?
27
+
28
+ private
29
+
30
+ def cap
31
+ @mutual ? "" : ">"
32
+ end
33
+ end
34
+
35
+ end