natural_dsl 0.0.2 → 0.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97856ddab791034315f5feac6ec0bb5648299d2374d2130b49f6601d315a710e
4
- data.tar.gz: 1f1fa4301a53ab7cedbad37cd68ebde01fd77df24fedbb033e30e632a8d5e6ac
3
+ metadata.gz: 358a31294950b2d1d67cf00b45adc4a519023f6d1ca529349af6850dfdcf8f90
4
+ data.tar.gz: 2d32cf63124eadf633e7808cbcc4de5cbd920ad6638c8d67cad395168d486511
5
5
  SHA512:
6
- metadata.gz: 03ead22cfb4c3d7020778bfad056a02c00fc3954a8f99450be770c4bfa79b7344459988bd62037eecac5250921be89afebe2ffb6a1095fb8459e204e846a951b
7
- data.tar.gz: 3cb2097e3e5bbf7df1e51cc508978ea5f3a313b161bb23f6aa4a543792f3651f466c0e42a91aa0debc2c22406bd20b9a9ec5710fae7105ad1c0526b115eb5ed7
6
+ metadata.gz: 64cab2794165a8bf2b2e06284bfd3d3073c76dbcd6ff330f7d6311d108e350200f68d739fd07f53b8b4416f7e7885f02e30e5f67e8a71a8df22c805b1d6de0de
7
+ data.tar.gz: b94d219a355615bf110a6d64a68d3e5340e7195be3ca1f5dcdc850b514bf67ee6d04a940d65499b4022851f97360a8c9e9c30b71524ec0c9f9b28f14fd5124f6
data/CHANGELOG.md CHANGED
@@ -2,3 +2,11 @@
2
2
 
3
3
  ## main
4
4
 
5
+ - [PR [#2](https://github.com/DmitryTsepelev/natural_dsl/pull/2)] Remove value expectation, add with_value modifier ([@DmitryTsepelev][])
6
+ - [PR [#1](https://github.com/DmitryTsepelev/natural_dsl/pull/1)] Web app example, better expectations ([@DmitryTsepelev][])
7
+
8
+ ## 0.2.0 (2022-07-26)
9
+
10
+ - Initial version. ([@DmitryTsepelev][])
11
+
12
+ [@DmitryTsepelev]: https://github.com/DmitryTsepelev
data/README.md CHANGED
@@ -9,7 +9,7 @@ lang = NaturalDSL::Lang.define do
9
9
  token
10
10
  keyword :to
11
11
  token
12
- value :takes
12
+ keyword(:takes).with_value
13
13
 
14
14
  execute do |vm, city1, city2, distance|
15
15
  distances = vm.read_variable(:distances) || {}
@@ -47,4 +47,154 @@ end
47
47
  puts result # => Travel from london to glasgow takes 22 hours
48
48
  ```
49
49
 
50
- Read more about this experiment in by [blog](https://dmitrytsepelev.dev/natural-language-programming-with-ruby).
50
+ Read more about this experiment in my [blog](https://dmitrytsepelev.dev/natural-language-programming-with-ruby).
51
+
52
+ ## Language definition
53
+
54
+ ### Command syntax
55
+
56
+ Each _language_ consists of _commands_. Command can contain _keywords_, _tokens_ and _values_:
57
+
58
+ - _keyword_ is something you want to be in the command to be semantically correct, but you don't need to have it to execute the command (e.g., `to`, `from`, etc.);
59
+ - _token_ is anything that user types, and the typed word will be passed to the execution block;
60
+ - _value_ can be read right after the last keyword or token with `with_value` modifier (e.g., `value 42`).
61
+
62
+ For instance:
63
+
64
+ ```
65
+ keyword token value
66
+ ↓ ↓ ↓
67
+ assign variable a value 1
68
+ ↑ ↑
69
+ command name keyword
70
+ ```
71
+
72
+ ### Command execution
73
+
74
+ Command makes no sense without logic it implements. We can configure it using the _execute_ method: it receives the instance of the current _Virtual Machine_ as well as all tokens and values:
75
+
76
+ ```ruby
77
+ execute do |vm, *args|
78
+ # logic goes here
79
+ end
80
+ ```
81
+
82
+ This is how we can create a very basic command that remembers values:
83
+
84
+ ```ruby
85
+ command :assign do
86
+ keyword :variable
87
+ token
88
+ keyword(:value).with_value
89
+
90
+ execute do |vm, token, value|
91
+ # how to assign?
92
+ end
93
+ end
94
+ ```
95
+
96
+ ### Shared data
97
+
98
+ We need to store the data somewhere between commands, and Virtual Machine has that storage, which can be accessed using `assign_variable` and `read_variable`. Here is the whole definition of language that can store and sum variables:
99
+
100
+ ```ruby
101
+ lang = NaturalDSL::Lang.define do
102
+ command :assign do
103
+ keyword :variable
104
+ token
105
+ keyword(:value).with_value
106
+
107
+ execute { |vm, token, value| vm.assign_variable(token, value) }
108
+ end
109
+
110
+ command :sum do
111
+ token
112
+ keyword :with
113
+ token
114
+
115
+ execute do |vm, left, right|
116
+ vm.read_variable(left).value + vm.read_variable(right).value
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Running languages
123
+
124
+ Finally, we can run the program written in our new DSL using the `VM` class:
125
+
126
+ ```ruby
127
+ NaturalDSL::VM.run(lang) do
128
+ assign variable a value 1
129
+ assign variable b value 2
130
+ sum a with b
131
+ end
132
+ ```
133
+
134
+ ### Multiple primitives
135
+
136
+ Need to consume the unknown amount of similar primitives? Use `zero_or_more`:
137
+
138
+ ```ruby
139
+ lang = NaturalDSL::Lang.define do
140
+ command :expose do
141
+ token.zero_or_more
142
+
143
+ execute { |_, *fields| "exposing #{fields.join(', ')}" }
144
+ end
145
+ end
146
+
147
+ result = NaturalDSL::VM.run(lang) do
148
+ expose id email
149
+ end
150
+
151
+ puts result # => exposing id, email
152
+ ```
153
+
154
+ ### Alternative name for #value
155
+
156
+ Sometimes you don't want to see the word `value` in your commands. In this case you can rename it by passing an argument:
157
+
158
+ ```ruby
159
+ lang = NaturalDSL::Lang.define do
160
+ command :john do
161
+ keyword(:takes).with_value
162
+ execute { |vm, value| vm.assign_variable(:john, value) }
163
+ end
164
+
165
+ command :jane do
166
+ keyword(:takes).with_value
167
+ execute { |vm, value| vm.assign_variable(:jane, value) }
168
+ end
169
+
170
+ command :who do
171
+ keyword :has
172
+ keyword :more
173
+
174
+ execute do |vm|
175
+ name = %i[john jane].max_by { |person| vm.read_variable(person).value }
176
+ "#{name} has more apples!"
177
+ end
178
+ end
179
+ end
180
+
181
+ result = NaturalDSL::VM.run(lang) do
182
+ john takes 2
183
+ jane takes 3
184
+ who has more
185
+ end
186
+
187
+ puts result # => jane has more
188
+ ```
189
+
190
+ ## Installation
191
+
192
+ Add this line to your application's Gemfile, and you're all set:
193
+
194
+ ```ruby
195
+ gem "natural_dsl"
196
+ ```
197
+
198
+ ## License
199
+
200
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,4 +1,5 @@
1
1
  require "natural_dsl/command_runner"
2
+ require "natural_dsl/expectations"
2
3
 
3
4
  module NaturalDSL
4
5
  class Command
@@ -13,7 +14,14 @@ module NaturalDSL
13
14
  end
14
15
 
15
16
  def build(&block)
16
- tap { |command| command.instance_eval(&block) }
17
+ tap do |command|
18
+ command.instance_eval(&block)
19
+
20
+ invalid_expectations = command.expectations[0..-2].select(&:with_value?)
21
+ if invalid_expectations.any?
22
+ raise "Command #{command.name} attempts to consume value after #{invalid_expectations.first}"
23
+ end
24
+ end
17
25
  end
18
26
 
19
27
  def run(vm)
@@ -24,23 +32,18 @@ module NaturalDSL
24
32
  @expectations ||= []
25
33
  end
26
34
 
27
- def value_method_names
28
- @value_method_names ||= []
29
- end
30
-
31
35
  private
32
36
 
33
37
  def token
34
- expectations << Primitives::Token
35
- end
36
-
37
- def value(method_name = :value)
38
- value_method_names << method_name
39
- expectations << Primitives::Value
38
+ Expectations::Token.new.tap do |expectation|
39
+ expectations << expectation
40
+ end
40
41
  end
41
42
 
42
43
  def keyword(type)
43
- expectations << Primitives::Keyword.new(type)
44
+ Expectations::Keyword.new(type).tap do |expectation|
45
+ expectations << expectation
46
+ end
44
47
  end
45
48
 
46
49
  def execute(&block)
@@ -12,7 +12,9 @@ module NaturalDSL
12
12
  end
13
13
 
14
14
  def run
15
- args = @command.expectations.each_with_object([], &method(:check_expectation))
15
+ args = @command.expectations.flat_map do |expectation|
16
+ expectation.read_arguments(@vm.stack)
17
+ end
16
18
 
17
19
  raise_stack_not_empty_error if @vm.stack.any?
18
20
 
@@ -21,14 +23,6 @@ module NaturalDSL
21
23
 
22
24
  private
23
25
 
24
- def check_expectation(expectation, args)
25
- if expectation.is_a?(Primitives::Keyword)
26
- @vm.stack.pop_if_keyword(expectation.type)
27
- else
28
- args << @vm.stack.pop_if(expectation)
29
- end
30
- end
31
-
32
26
  def raise_stack_not_empty_error
33
27
  class_names = @vm.stack.map { |primitive| primitive.class.name.demodulize }
34
28
 
@@ -0,0 +1,58 @@
1
+ module NaturalDSL
2
+ module Expectations
3
+ class Base
4
+ class << self
5
+ def modifiers
6
+ @@modifiers ||= []
7
+ end
8
+
9
+ def modifier(name, conflicts:)
10
+ modifiers << name
11
+
12
+ define_method(name) do
13
+ Array(conflicts).each do |conflict|
14
+ if public_send("#{conflict}?")
15
+ raise "#{name} cannot be configured for #{self.class.name} with #{conflict}"
16
+ end
17
+ end
18
+
19
+ instance_variable_set("@#{name}", true)
20
+ end
21
+
22
+ define_method("#{name}?") { instance_variable_get("@#{name}") }
23
+ end
24
+ end
25
+
26
+ modifier :zero_or_more, conflicts: :with_value
27
+ modifier :with_value, conflicts: :zero_or_more
28
+
29
+ def initialize
30
+ self.class.modifiers.each { |name| instance_variable_set("@#{name}", false) }
31
+ end
32
+
33
+ def read_arguments(stack)
34
+ [].tap do |args|
35
+ if zero_or_more?
36
+ loop do
37
+ arg = perform_read(stack, raise: false)
38
+ break if arg.nil?
39
+
40
+ args << arg unless arg.is_a?(Primitives::Keyword)
41
+ end
42
+ else
43
+ arg = perform_read(stack, raise: true)
44
+ args << arg unless arg.is_a?(Primitives::Keyword)
45
+
46
+ args << stack.pop_if(Primitives::Value, raise: true) if with_value?
47
+ end
48
+ end
49
+ end
50
+
51
+ protected
52
+
53
+ def perform_read(*)
54
+ raise NotImplementedError
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,22 @@
1
+ module NaturalDSL
2
+ module Expectations
3
+ class Keyword < Base
4
+ attr_reader :type
5
+
6
+ def initialize(type)
7
+ @type = type
8
+ super()
9
+ end
10
+
11
+ def to_s
12
+ "keyword :#{type}"
13
+ end
14
+
15
+ protected
16
+
17
+ def perform_read(stack, raise:)
18
+ stack.pop_if_keyword(type, raise: raise)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ module NaturalDSL
2
+ module Expectations
3
+ class Token < Base
4
+ def to_s
5
+ "token"
6
+ end
7
+
8
+ protected
9
+
10
+ def perform_read(stack, raise:)
11
+ stack.pop_if(Primitives::Token, raise: raise)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ require "natural_dsl/expectations/base"
2
+ require "natural_dsl/expectations/token"
3
+ require "natural_dsl/expectations/keyword"
@@ -23,11 +23,9 @@ module NaturalDSL
23
23
  private
24
24
 
25
25
  def register_keywords(command)
26
- command.expectations.filter(&method(:keyword?)).each(&method(:register_keyword))
27
- end
28
-
29
- def keyword?(expectation)
30
- expectation.is_a?(Primitives::Keyword)
26
+ command.expectations
27
+ .filter { |expectation| expectation.is_a?(Expectations::Keyword) }
28
+ .each(&method(:register_keyword))
31
29
  end
32
30
 
33
31
  def register_keyword(keyword)
@@ -2,18 +2,21 @@ module NaturalDSL
2
2
  class Stack < Array
3
3
  using StringDemodulize
4
4
 
5
- def pop_if(expected_class)
5
+ def pop_if(expected_class, raise: true)
6
6
  return pop if last.is_a?(expected_class)
7
+ return unless raise
7
8
 
8
9
  error_reason = empty? ? "stack was empty" : "got #{last.class.name.demodulize}"
9
10
  raise "Expected #{expected_class.name.demodulize} but #{error_reason}"
10
11
  end
11
12
 
12
- def pop_if_keyword(keyword_type)
13
- pop_if(Primitives::Keyword).tap do |keyword|
14
- next if keyword.type == keyword_type
15
-
13
+ def pop_if_keyword(keyword_type, raise: true)
14
+ pop_if(Primitives::Keyword, raise: raise).tap do |keyword|
15
+ next if raise == false && keyword.nil? || keyword.type == keyword_type
16
16
  push(keyword)
17
+
18
+ next unless raise
19
+
17
20
  raise "Expected #{keyword_type} but got #{keyword.type}"
18
21
  end
19
22
  end
@@ -1,3 +1,3 @@
1
1
  module NaturalDSL
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -5,7 +5,7 @@ module NaturalDSL
5
5
  class << self
6
6
  def build(lang)
7
7
  lang.commands.each do |command_name, command|
8
- define_command(lang, command_name, command)
8
+ define_method(command_name) { |*| command.run(self) }
9
9
  end
10
10
 
11
11
  new(lang)
@@ -14,18 +14,6 @@ module NaturalDSL
14
14
  def run(lang, &block)
15
15
  build(lang).run(&block)
16
16
  end
17
-
18
- private
19
-
20
- def define_command(lang, command_name, command)
21
- define_method(command_name) { |*| command.run(self) }
22
-
23
- command.value_method_names.each do |value_method_name|
24
- define_method(value_method_name) do |value|
25
- @stack << NaturalDSL::Primitives::Value.new(value)
26
- end
27
- end
28
- end
29
17
  end
30
18
 
31
19
  attr_reader :variables, :stack
@@ -33,7 +21,7 @@ module NaturalDSL
33
21
  def initialize(lang)
34
22
  @lang = lang
35
23
  @variables = {}
36
- @stack = NaturalDSL::Stack.new
24
+ @stack = Stack.new
37
25
  end
38
26
 
39
27
  def run(&block)
@@ -48,18 +36,31 @@ module NaturalDSL
48
36
  @variables[token.name]
49
37
  end
50
38
 
51
- def method_missing(unknown, *args, &block)
39
+ def method_missing(unknown, *values, &block)
52
40
  klass = if @lang.keywords.include?(unknown)
53
- NaturalDSL::Primitives::Keyword
41
+ Primitives::Keyword
54
42
  else
55
- NaturalDSL::Primitives::Token
43
+ Primitives::Token
56
44
  end
57
45
 
46
+ lookup_value_in(values.flatten)
47
+
58
48
  @stack << klass.new(unknown)
59
49
  end
60
50
 
61
51
  def respond_to_missing?(*)
62
52
  true
63
53
  end
54
+
55
+ private
56
+
57
+ def lookup_value_in(values)
58
+ return if values.length != 1
59
+ candidate = values.first
60
+
61
+ return if candidate.is_a?(Primitives::Keyword) || candidate.is_a?(Primitives::Token)
62
+
63
+ @stack << Primitives::Value.new(candidate)
64
+ end
64
65
  end
65
66
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: natural_dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DmitryTsepelev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-07-26 00:00:00.000000000 Z
11
+ date: 2022-07-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -23,6 +23,10 @@ files:
23
23
  - lib/natural_dsl.rb
24
24
  - lib/natural_dsl/command.rb
25
25
  - lib/natural_dsl/command_runner.rb
26
+ - lib/natural_dsl/expectations.rb
27
+ - lib/natural_dsl/expectations/base.rb
28
+ - lib/natural_dsl/expectations/keyword.rb
29
+ - lib/natural_dsl/expectations/token.rb
26
30
  - lib/natural_dsl/lang.rb
27
31
  - lib/natural_dsl/primitives.rb
28
32
  - lib/natural_dsl/stack.rb