chutzen 0.8.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 +7 -0
- data/README.md +244 -0
- data/lib/chutzen/apply.rb +49 -0
- data/lib/chutzen/command/execution_failed.rb +42 -0
- data/lib/chutzen/command.rb +216 -0
- data/lib/chutzen/demux.rb +35 -0
- data/lib/chutzen/dictionary.rb +135 -0
- data/lib/chutzen/expression/lookup_error.rb +30 -0
- data/lib/chutzen/expression/syntax_error.rb +28 -0
- data/lib/chutzen/expression.rb +74 -0
- data/lib/chutzen/expression_parser.rb +47 -0
- data/lib/chutzen/job.rb +80 -0
- data/lib/chutzen/notification.rb +32 -0
- data/lib/chutzen/runtime_error.rb +21 -0
- data/lib/chutzen/shell.rb +27 -0
- data/lib/chutzen/signal.rb +49 -0
- data/lib/chutzen/standard_error.rb +10 -0
- data/lib/chutzen/template/syntax_error.rb +28 -0
- data/lib/chutzen/template.rb +39 -0
- data/lib/chutzen/template_parser.rb +17 -0
- data/lib/chutzen/version.rb +5 -0
- data/lib/chutzen/watcher.rb +111 -0
- data/lib/chutzen/worker.rb +16 -0
- data/lib/chutzen.rb +87 -0
- metadata +136 -0
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
# Holds a hash and allows retrieval of data with a simple lookup syntax.
|
5
|
+
class Dictionary
|
6
|
+
# Creates a new Dictionary with an option initial hash of data.
|
7
|
+
def initialize(data = {})
|
8
|
+
@data = data
|
9
|
+
@mutex = Mutex.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_hash
|
13
|
+
@data
|
14
|
+
end
|
15
|
+
|
16
|
+
# Get a value using a dot expression (eg. 'export.filename'). Does not work with deep search
|
17
|
+
# indentifiers.
|
18
|
+
def [](key)
|
19
|
+
dig(*Chutzen::Expression.split(key))
|
20
|
+
end
|
21
|
+
|
22
|
+
def []=(key, value)
|
23
|
+
@mutex.synchronize do
|
24
|
+
@data[key.to_s] = value
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns true when a value was set for the path, so that means it also returns true when the
|
29
|
+
# value is nil.
|
30
|
+
def exist?(*path)
|
31
|
+
data = path.length > 1 ? @data.dig(*path[0..-2]) : @data
|
32
|
+
data&.key?(path.last)
|
33
|
+
end
|
34
|
+
|
35
|
+
# See Hash#dig.
|
36
|
+
def dig(*path)
|
37
|
+
@data.dig(*path)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Performs a depth first search for the first item in the path. After that it works just like
|
41
|
+
# dig on the rest of the path.
|
42
|
+
def dig_deep(*path)
|
43
|
+
found_path = self.class.search(@data, path[0])
|
44
|
+
found_path ? @data.dig(*(found_path.reverse + path[1..])) : nil
|
45
|
+
end
|
46
|
+
|
47
|
+
# Add or replace a value with a path.
|
48
|
+
def bury!(path, value)
|
49
|
+
@mutex.synchronize do
|
50
|
+
self.class.bury(@data, path, value)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Merge a hash into the dictionary.
|
55
|
+
def merge!(data)
|
56
|
+
@mutex.synchronize do
|
57
|
+
@data.merge!(data)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Merge a hash into the dictionary without replacing existing values.
|
62
|
+
def deep_merge!(data)
|
63
|
+
@mutex.synchronize do
|
64
|
+
DeepMerge.new(@data, data).perform
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Searches the data depth-first for a key and returns the path to that
|
69
|
+
# key for the first match.
|
70
|
+
def self.search(data, key)
|
71
|
+
case data
|
72
|
+
when Hash
|
73
|
+
search_hash(data, key)
|
74
|
+
when Array
|
75
|
+
search_array(data, key)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.search_hash(data, key)
|
80
|
+
if data.key?(key)
|
81
|
+
[key]
|
82
|
+
else
|
83
|
+
data.each do |name, value|
|
84
|
+
path = search(value, key)
|
85
|
+
return (path << name) if path
|
86
|
+
end
|
87
|
+
nil
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.search_array(data, key)
|
92
|
+
data.each.with_index do |item, index|
|
93
|
+
path = search(item, key)
|
94
|
+
return (path << index) if path
|
95
|
+
end
|
96
|
+
nil
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.bury(data, path, value)
|
100
|
+
key, *rest = path
|
101
|
+
if rest.empty?
|
102
|
+
data[key] = value
|
103
|
+
elsif data.key?(key)
|
104
|
+
bury(data[key], rest, value)
|
105
|
+
else
|
106
|
+
data[key] = build(rest, value)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.build(path, value)
|
111
|
+
reference, *rest = path
|
112
|
+
if reference.is_a?(Numeric) && reference < 256
|
113
|
+
build_array(reference, rest, value)
|
114
|
+
else
|
115
|
+
build_hash(reference, rest, value)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.build_array(index, rest, value)
|
120
|
+
if rest.empty?
|
121
|
+
[].insert(index, value)
|
122
|
+
else
|
123
|
+
[].insert(index, build(rest, value))
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.build_hash(key, rest, value)
|
128
|
+
if rest.empty?
|
129
|
+
{ key => value }
|
130
|
+
else
|
131
|
+
{ key => build(rest, value) }
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
class Expression
|
5
|
+
# Raised when Chutzen can't find a value in the job dictionary.
|
6
|
+
class LookupError < Chutzen::StandardError
|
7
|
+
attr_reader :expression, :dictionary
|
8
|
+
|
9
|
+
def initialize(message, expression: nil, dictionary: nil)
|
10
|
+
super(message)
|
11
|
+
@expression = expression
|
12
|
+
@dictionary = dictionary
|
13
|
+
end
|
14
|
+
|
15
|
+
def as_json
|
16
|
+
{ 'error' => details }
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def details
|
22
|
+
{
|
23
|
+
'message' => message,
|
24
|
+
'expression' => @expression&.to_s,
|
25
|
+
'dictionary' => @dictionary&.to_hash
|
26
|
+
}.compact
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
class Expression
|
5
|
+
# Raised when something went wrong while parsing a Chutzen expression.
|
6
|
+
class SyntaxError < Chutzen::StandardError
|
7
|
+
attr_reader :expression
|
8
|
+
|
9
|
+
def initialize(message, expression: nil)
|
10
|
+
super(message)
|
11
|
+
@expression = expression
|
12
|
+
end
|
13
|
+
|
14
|
+
def as_json
|
15
|
+
{ 'error' => details }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def details
|
21
|
+
{
|
22
|
+
'message' => message,
|
23
|
+
'expression' => @expression&.to_s
|
24
|
+
}.compact
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parslet'
|
4
|
+
|
5
|
+
module Chutzen
|
6
|
+
# Transforms a Chutzen expression by transforming it and replacing any query expressions with
|
7
|
+
# their values.
|
8
|
+
class Expression
|
9
|
+
autoload :SyntaxError, 'chutzen/expression/syntax_error'
|
10
|
+
autoload :LookupError, 'chutzen/expression/lookup_error'
|
11
|
+
|
12
|
+
INTEGER_RE = /\A\d+\z/.freeze
|
13
|
+
OPERATIONS = {
|
14
|
+
'=' => ->(left, right) { left == right },
|
15
|
+
'&' => ->(left, right) { left & right },
|
16
|
+
'|' => ->(left, right) { left | right },
|
17
|
+
'<' => ->(left, right) { left & right ? left < right : nil },
|
18
|
+
'>' => ->(left, right) { left & right ? left > right : nil }
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
Transformer = Parslet::Transform.new do
|
22
|
+
rule(undefined: simple(:value)) { nil }
|
23
|
+
rule(boolean: simple(:value)) { value.to_s == 'true' }
|
24
|
+
rule(integer: simple(:value)) { value.to_i }
|
25
|
+
rule(float: simple(:value)) { value.to_f }
|
26
|
+
rule(string: simple(:value)) { value.to_s }
|
27
|
+
|
28
|
+
rule(identifier: simple(:name)) do
|
29
|
+
path = *Chutzen::Expression.split(name)
|
30
|
+
if dictionary.exist?(*path)
|
31
|
+
dictionary.dig(*path)
|
32
|
+
else
|
33
|
+
raise(
|
34
|
+
LookupError.new(
|
35
|
+
"No value for #{name}", expression: name.to_s, dictionary: dictionary
|
36
|
+
)
|
37
|
+
)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
rule(search_identifier: simple(:name)) do
|
42
|
+
dictionary.dig_deep(*Chutzen::Expression.split(name))
|
43
|
+
end
|
44
|
+
|
45
|
+
rule(
|
46
|
+
operation: {
|
47
|
+
left: simple(:left), operator: simple(:operator), right: simple(:right)
|
48
|
+
}
|
49
|
+
) do
|
50
|
+
OPERATIONS[operator.to_s].call(left, right)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(expression, dictionary)
|
55
|
+
@expression = Chutzen::ExpressionParser.new.parse(expression)
|
56
|
+
@dictionary = dictionary
|
57
|
+
rescue Parslet::ParseFailed => e
|
58
|
+
raise Chutzen::Expression::SyntaxError.new(
|
59
|
+
e.parse_failure_cause.ascii_tree,
|
60
|
+
expression: expression
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
def result
|
65
|
+
Transformer.apply(@expression, dictionary: @dictionary)
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.split(key)
|
69
|
+
key.to_s.split('.').map do |segment|
|
70
|
+
INTEGER_RE.match(segment) ? segment.to_i : segment
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parslet'
|
4
|
+
|
5
|
+
module Chutzen
|
6
|
+
# Parses an expression in the Chutzen language.
|
7
|
+
class ExpressionParser < Parslet::Parser
|
8
|
+
rule(:spaces) { match('\s').repeat(1) }
|
9
|
+
rule(:spaces?) { spaces.maybe }
|
10
|
+
|
11
|
+
rule(:numeric) { match('[0-9]').repeat(1) }
|
12
|
+
rule(:integer) { numeric.as(:integer) }
|
13
|
+
rule(:float) { ((numeric >> str('.')).repeat(1) >> numeric).as(:float) }
|
14
|
+
|
15
|
+
rule(:undefined) { str('undefined').as(:undefined) }
|
16
|
+
rule(:boolean) { (str('true') | str('false')).as(:boolean) }
|
17
|
+
|
18
|
+
rule(:quote) { str("'") }
|
19
|
+
rule(:string) { match("[^']").repeat(1) }
|
20
|
+
rule(:quoted_string) { quote >> string.as(:string) >> quote }
|
21
|
+
|
22
|
+
rule(:literal) { undefined | float | integer | boolean | quoted_string }
|
23
|
+
|
24
|
+
rule(:search) { str('//') }
|
25
|
+
rule(:key) { match['\w'].repeat(1) }
|
26
|
+
rule(:atom) { key | integer }
|
27
|
+
rule(:absolute_identifier) { atom >> (str('.') >> atom).repeat }
|
28
|
+
rule(:search_identifier) { search >> absolute_identifier.as(:search_identifier) }
|
29
|
+
rule(:identifier) do
|
30
|
+
search_identifier | absolute_identifier.as(:identifier)
|
31
|
+
end
|
32
|
+
|
33
|
+
rule(:simple_expression) { literal | identifier }
|
34
|
+
|
35
|
+
rule(:operator) { match('[&|=<>]') }
|
36
|
+
rule(:operation) do
|
37
|
+
simple_expression.as(:left) >> spaces? >>
|
38
|
+
operator.as(:operator) >> spaces? >>
|
39
|
+
simple_expression.as(:right)
|
40
|
+
end
|
41
|
+
|
42
|
+
rule(:expression) do
|
43
|
+
operation.as(:operation) | simple_expression
|
44
|
+
end
|
45
|
+
root(:expression)
|
46
|
+
end
|
47
|
+
end
|
data/lib/chutzen/job.rb
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'etc'
|
4
|
+
|
5
|
+
module Chutzen
|
6
|
+
# Holds a description for a job and executes it.
|
7
|
+
class Job
|
8
|
+
include Enumerable
|
9
|
+
|
10
|
+
def initialize(description)
|
11
|
+
@notify = nil
|
12
|
+
@result = {}
|
13
|
+
description.each do |name, value|
|
14
|
+
instance_variable_set("@#{name}", value)
|
15
|
+
end
|
16
|
+
@uuid = SecureRandom.uuid
|
17
|
+
@work_path = File.join(ENV['HOME'], 'chutzen', @uuid)
|
18
|
+
@dictionary = Dictionary.new(properties)
|
19
|
+
end
|
20
|
+
|
21
|
+
def properties
|
22
|
+
{
|
23
|
+
'uuid' => @uuid,
|
24
|
+
'work_path' => @work_path,
|
25
|
+
'uname' => Etc.uname.transform_keys(&:to_s),
|
26
|
+
'nprocs' => Etc.nprocessors,
|
27
|
+
'chutzen_version' => Chutzen::VERSION
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
# Yields Command instances for each command in the description.
|
32
|
+
def each
|
33
|
+
@commands.each do |description|
|
34
|
+
yield Command.new(
|
35
|
+
description,
|
36
|
+
dictionary: @dictionary,
|
37
|
+
work_path: @work_path
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Perform all commands in the job.
|
43
|
+
def perform
|
44
|
+
FileUtils.mkdir_p(@work_path)
|
45
|
+
map do |command|
|
46
|
+
perform_notification(command.perform)
|
47
|
+
command
|
48
|
+
end
|
49
|
+
rescue Chutzen::StandardError => e
|
50
|
+
warn("Job failed: #{e}")
|
51
|
+
perform_exception_notification(e)
|
52
|
+
ensure
|
53
|
+
FileUtils.rm_rf(@work_path)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def global_result
|
59
|
+
@result || {}
|
60
|
+
end
|
61
|
+
|
62
|
+
# Pushes a notification into the Sidekiq queue for each command that
|
63
|
+
# produces a result.
|
64
|
+
def perform_notification(command)
|
65
|
+
return unless @notify
|
66
|
+
return unless command
|
67
|
+
|
68
|
+
result = command.result
|
69
|
+
return unless result
|
70
|
+
|
71
|
+
Notification.perform_async(@notify, global_result.merge(result))
|
72
|
+
end
|
73
|
+
|
74
|
+
def perform_exception_notification(error)
|
75
|
+
return unless @notify
|
76
|
+
|
77
|
+
Notification.perform_async(@notify, global_result.merge(error.as_json))
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
# Helper class to push a Sidekiq job on the specified callback queue with the
|
5
|
+
# specified class and arguments.
|
6
|
+
class Notification
|
7
|
+
# Returns defaults for a Sidekiq job. See Sidekiq documentation for value
|
8
|
+
# params. We suggest using 'queue', 'class', and 'args'.
|
9
|
+
def self.defaults
|
10
|
+
{ 'queue' => 'default' }
|
11
|
+
end
|
12
|
+
|
13
|
+
# Schedules a notification by creating a Sidekiq job using the job
|
14
|
+
# description and a Ruby object as a payload.
|
15
|
+
#
|
16
|
+
# Notification.perform_async(
|
17
|
+
# { 'class' => 'MyApplication::Result' },
|
18
|
+
# { 'filename' => 'ok.txt', 'name' => 'ok' }
|
19
|
+
# )
|
20
|
+
#
|
21
|
+
# The payload is serialized as JSON to reduce potential interoperability
|
22
|
+
# problems and to make it easier to write forward and backward support
|
23
|
+
# for the worker classes.
|
24
|
+
def self.perform_async(job, payload)
|
25
|
+
Sidekiq::Client.push(
|
26
|
+
defaults.merge(
|
27
|
+
job.merge('args' => [JSON.dump(payload)])
|
28
|
+
)
|
29
|
+
)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
# Raised when something went wrong while evaluating a Chutzen expression.
|
5
|
+
class RuntimeError < Chutzen::StandardError
|
6
|
+
def initialize(message, **details)
|
7
|
+
super(message)
|
8
|
+
@details = details
|
9
|
+
end
|
10
|
+
|
11
|
+
def as_json
|
12
|
+
{ 'error' => details }
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def details
|
18
|
+
{ 'message' => message }.merge(@details.transform_keys(&:to_s))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
# Implements utilities to safely run commands on the shell.
|
5
|
+
class Shell
|
6
|
+
def self.join(execute)
|
7
|
+
case execute
|
8
|
+
when Hash
|
9
|
+
join_hash(execute)
|
10
|
+
when Array
|
11
|
+
join_array(execute)
|
12
|
+
else
|
13
|
+
execute
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.join_hash(execute)
|
18
|
+
execute.map do |name, value|
|
19
|
+
value ? "#{name} #{value}" : name
|
20
|
+
end.join(' ')
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.join_array(execute)
|
24
|
+
execute.map { |part| join(part) }.join(' ')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sidekiq'
|
4
|
+
require 'sidekiq/cli'
|
5
|
+
|
6
|
+
module Chutzen
|
7
|
+
# Sidekiq worker to accept operational signals and commands through the queue.
|
8
|
+
class Signal
|
9
|
+
include Sidekiq::Worker
|
10
|
+
|
11
|
+
sidekiq_options queue: Chutzen.sidekiq_queue, retry: false
|
12
|
+
|
13
|
+
# Send a signal to the Chutzen process.
|
14
|
+
#
|
15
|
+
# == Signal names
|
16
|
+
#
|
17
|
+
# * +stop+ stops the current process from accepting new jobs
|
18
|
+
#
|
19
|
+
# @param signal [string] the signal you want to send
|
20
|
+
# @param expires_at [integer] ignore signal after this time (in seconds
|
21
|
+
# since 1970)
|
22
|
+
def perform(signal, expires_at = nil)
|
23
|
+
if fresh?(expires_at)
|
24
|
+
perform_signal(signal)
|
25
|
+
else
|
26
|
+
Sidekiq.logger.info("Ignoring signal `#{signal}' because it expired at #{expires_at}.")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def fresh?(expires_at)
|
33
|
+
if expires_at
|
34
|
+
Time.at(expires_at) > Time.now
|
35
|
+
else
|
36
|
+
true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def perform_signal(signal)
|
41
|
+
case signal
|
42
|
+
when 'stop'
|
43
|
+
Sidekiq::CLI.instance.handle_signal('TSTP')
|
44
|
+
else
|
45
|
+
raise(ArgumentError, "Unknown signal `#{signal}'")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chutzen
|
4
|
+
class Template
|
5
|
+
# Raised when something went wrong while parsing a Chutzen template.
|
6
|
+
class SyntaxError < Chutzen::StandardError
|
7
|
+
attr_reader :template
|
8
|
+
|
9
|
+
def initialize(message, template: nil)
|
10
|
+
super(message)
|
11
|
+
@template = template
|
12
|
+
end
|
13
|
+
|
14
|
+
def as_json
|
15
|
+
{ 'error' => details }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def details
|
21
|
+
{
|
22
|
+
'message' => message,
|
23
|
+
'template' => @template&.to_s
|
24
|
+
}.compact
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parslet'
|
4
|
+
|
5
|
+
module Chutzen
|
6
|
+
# Replaces Chutzen queries and variable templates in a text template using a dictionary of
|
7
|
+
# values.
|
8
|
+
class Template
|
9
|
+
autoload :SyntaxError, 'chutzen/template/syntax_error'
|
10
|
+
|
11
|
+
Transformer = Parslet::Transform.new do
|
12
|
+
rule(expression: simple(:expression)) do
|
13
|
+
Chutzen::Expression.new(expression.to_s, dictionary).result
|
14
|
+
end
|
15
|
+
rule(global: simple(:name)) { dictionary[name.to_s] }
|
16
|
+
rule(string: simple(:value)) { value.to_s }
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(template, dictionary)
|
20
|
+
@template = Chutzen::TemplateParser.new.parse(template)
|
21
|
+
@dictionary = dictionary
|
22
|
+
rescue Parslet::ParseFailed => e
|
23
|
+
raise Chutzen::Template::SyntaxError.new(
|
24
|
+
e.parse_failure_cause.ascii_tree,
|
25
|
+
template: template
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
def result
|
30
|
+
result = Transformer.apply(@template, dictionary: @dictionary)
|
31
|
+
case result
|
32
|
+
when Array
|
33
|
+
result.join
|
34
|
+
else
|
35
|
+
result
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parslet'
|
4
|
+
|
5
|
+
module Chutzen
|
6
|
+
# Parses a template for variable expressions.
|
7
|
+
class TemplateParser < Parslet::Parser
|
8
|
+
rule(:string) { match('[^$]').repeat(1).as(:string) }
|
9
|
+
|
10
|
+
rule(:variable) { str('${') >> match('\\\}|[^}]').repeat(1).as(:expression) >> str('}') }
|
11
|
+
rule(:global) { str('@') >> match('\w').repeat(1).as(:global) }
|
12
|
+
rule(:part) { variable | string }
|
13
|
+
|
14
|
+
rule(:template) { str('') | global | part.repeat(1) }
|
15
|
+
root(:template)
|
16
|
+
end
|
17
|
+
end
|