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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +152 -2
- data/lib/natural_dsl/command.rb +15 -12
- data/lib/natural_dsl/command_runner.rb +3 -9
- data/lib/natural_dsl/expectations/base.rb +58 -0
- data/lib/natural_dsl/expectations/keyword.rb +22 -0
- data/lib/natural_dsl/expectations/token.rb +15 -0
- data/lib/natural_dsl/expectations.rb +3 -0
- data/lib/natural_dsl/lang.rb +3 -5
- data/lib/natural_dsl/stack.rb +8 -5
- data/lib/natural_dsl/version.rb +1 -1
- data/lib/natural_dsl/vm.rb +18 -17
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 358a31294950b2d1d67cf00b45adc4a519023f6d1ca529349af6850dfdcf8f90
|
4
|
+
data.tar.gz: 2d32cf63124eadf633e7808cbcc4de5cbd920ad6638c8d67cad395168d486511
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
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).
|
data/lib/natural_dsl/command.rb
CHANGED
@@ -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
|
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
|
-
|
35
|
-
|
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
|
-
|
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.
|
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
|
data/lib/natural_dsl/lang.rb
CHANGED
@@ -23,11 +23,9 @@ module NaturalDSL
|
|
23
23
|
private
|
24
24
|
|
25
25
|
def register_keywords(command)
|
26
|
-
command.expectations
|
27
|
-
|
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)
|
data/lib/natural_dsl/stack.rb
CHANGED
@@ -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
|
data/lib/natural_dsl/version.rb
CHANGED
data/lib/natural_dsl/vm.rb
CHANGED
@@ -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
|
-
|
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 =
|
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, *
|
39
|
+
def method_missing(unknown, *values, &block)
|
52
40
|
klass = if @lang.keywords.include?(unknown)
|
53
|
-
|
41
|
+
Primitives::Keyword
|
54
42
|
else
|
55
|
-
|
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
|
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-
|
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
|