dialekt 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 292d8eb12e8d5c07a6d08fcfc9b52f35bee2bba6f654f5651a6cdad705ad95a6
4
+ data.tar.gz: ba4af05bf33624ee2c466900224f79e8afebe5cddb9f0e5511796211be042ef6
5
+ SHA512:
6
+ metadata.gz: 527a36bf572db17d099d155833870ecaa605dba432144328fbd43fc9347163e40a77b88615df437c5cb3da468f7d968c0c0dcf6c7fcb46d1472818ca460edaeb
7
+ data.tar.gz: cda9eae770da3b8497c2a4e7d569c18c1e857b8beb1d963c5094750a24b8ead69e6d2feb29d4925791480127dc2e250b8e3e7f326d7f3f2c8b4dabb14ccaef0d
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # What is it?
2
+
3
+ With Dialekt you can easily define properties for DSL objects that support nice accessor methods, type checking and transformation.
4
+
5
+ Dialekt is based on [Docile], which is a great tool to create DSLs in Ruby. However, you will soon find yourself creating lots of repetetive code to implement your DSL accessors. Dialekt aims to simplify this task.
6
+
7
+ [Docile]: https://github.com/ms-ati/docile
8
+
9
+ ## Example
10
+
11
+ Let's assume you want to create a build tool called Backscratcher (sort of a [small rake][Backscratcher] to scratch an itch :-) that uses a DSL for configuration. Your tool supports tasks and dependencies between tasks, and tasks can be grouped into namespaces. You start by creating a model and using Dialekt to define the properties.
12
+
13
+ [Backscratcher]: https://en.wikipedia.org/wiki/Backscratcher
14
+
15
+ ```ruby
16
+ require "dialekt"
17
+ require "forwardable"
18
+
19
+ module Backscratcher
20
+ extend Forwardable
21
+
22
+ class Task
23
+ attr_reader :name
24
+
25
+ # Create a set property containing strings
26
+ dsl_set :dependencies, value_type: String
27
+
28
+ def initialize(name:)
29
+ @name = name
30
+ end
31
+ end
32
+
33
+ class FileTask < Task
34
+ end
35
+
36
+ class Namespace
37
+ attr_reader :name
38
+
39
+ # Create a tasks hash with string keys
40
+ dsl_map :tasks, key_type: String, value_type: Task do
41
+ # Create an accessor for task entries
42
+ entry :task, value_factory: ->(key:) { Task.new(name: key) }
43
+ # Create an accessor for file task entries
44
+ entry :file, value_type: FileTask, value_factory: ->(key:) { FileTask.new(name: key) }
45
+ end
46
+
47
+ # Create a namespace hash with string keys
48
+ dsl_map :namespaces, key_type: String, value_type: Namespace do
49
+ # Create an accessor for namespace entries
50
+ entry :namespace, value_factory: ->(key:) { Namespace.new(name: key) }
51
+ end
52
+
53
+ def initialize(name:)
54
+ @name = name
55
+ @namespaces = {}
56
+ @tasks = {}
57
+ end
58
+ end
59
+
60
+ # Make some methods available in the root namespace for convenience
61
+ def_delegators :root, :namespace, :task, :file
62
+
63
+ def root
64
+ @root ||= Namespace.new(name: "")
65
+ end
66
+ end
67
+
68
+ include Backscratcher
69
+ ```
70
+
71
+ You now have a DSL for your build tool giving you methods to create namespaces, tasks and dependencies. Dialekt will handle the definition of accessores, type checking, creating new collection entries and applying DSL configurations:
72
+
73
+ ```ruby
74
+ task "build" do
75
+ dependency "db:create"
76
+ dependency "db:load"
77
+ end
78
+
79
+ file "test.txt"
80
+
81
+ namespace "db" do
82
+ task "create"
83
+
84
+ task "load" do
85
+ dependencies ["db:create"]
86
+ end
87
+ end
88
+ ```
data/dialekt.gemspec ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ LIB_DIR = File.join(__dir__, "lib")
4
+ $LOAD_PATH.unshift(LIB_DIR) unless $LOAD_PATH.include?(LIB_DIR)
5
+
6
+ require "dialekt/version"
7
+ require "json"
8
+ require "pathname"
9
+
10
+ Gem::Specification.new do |spec|
11
+ raise "RubyGems 2.0 or newer is required." unless spec.respond_to?(:metadata)
12
+
13
+ spec.name = "dialekt"
14
+ spec.version = Dialekt::VERSION
15
+ spec.summary = "DSL utilities"
16
+
17
+ spec.required_ruby_version = ">= 2.6"
18
+
19
+ spec.authors = ["Jochen Seeber"]
20
+ spec.email = ["jochen@seeber.me"]
21
+ spec.homepage = "https://github.com/jochenseeber/dialekt"
22
+
23
+ spec.metadata["issue_tracker"] = "https://github.com/jochenseeber/dialekt/issues"
24
+ spec.metadata["documentation"] = "http://jochenseeber.github.com/dialekt"
25
+ spec.metadata["source_code"] = "https://github.com/jochenseeber/dialekt"
26
+ spec.metadata["wiki"] = "https://github.com/jochenseeber/dialekt/wiki"
27
+
28
+ spec.files = Dir[
29
+ "*.gemspec",
30
+ "*.md",
31
+ "*.txt",
32
+ "lib/**/*.rb",
33
+ ]
34
+
35
+ spec.require_paths = [
36
+ "lib",
37
+ ]
38
+
39
+ spec.bindir = "cmd"
40
+ spec.executables = spec.files.filter { |f| File.dirname(f) == "cmd" && File.file?(f) }.map { |f| File.basename(f) }
41
+
42
+ spec.add_dependency "docile", "~> 1.3.5"
43
+ spec.add_dependency "dry-inflector", "~> 0.2"
44
+ spec.add_dependency "zeitwerk", "~> 2.3"
45
+
46
+ spec.add_development_dependency "bundler", "~> 2.1"
47
+ spec.add_development_dependency "calificador", "~> 0.2.0"
48
+ spec.add_development_dependency "debase", "~> 0.2"
49
+ spec.add_development_dependency "minitest", "~> 5.14"
50
+ spec.add_development_dependency "qed", "~> 2.9"
51
+ spec.add_development_dependency "rake", "~> 13.0"
52
+ spec.add_development_dependency "rubocop", "~> 1.6"
53
+ spec.add_development_dependency "rubocop-minitest", "~> 0.10"
54
+ spec.add_development_dependency "rubocop-rake", "~> 0.5"
55
+ spec.add_development_dependency "ruby-debug-ide", "~> 0.7"
56
+ spec.add_development_dependency "simplecov", "~> 0.18"
57
+ spec.add_development_dependency "yard", "~> 0.9"
58
+ end
data/lib/dialekt.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "zeitwerk"
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.setup
8
+
9
+ # Main module
10
+ module Dialekt
11
+ class Empty
12
+ include Singleton
13
+
14
+ def to_s
15
+ "<empty>"
16
+ end
17
+
18
+ alias_method :inspect, :to_s
19
+ end
20
+
21
+ EMPTY = Empty.instance
22
+ end
23
+
24
+ require "dialekt/dsl"
25
+ require "dialekt/util/core_extensions"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pp"
4
+ require "stringio"
5
+
6
+ module Dialekt
7
+ # Type checker
8
+ class BasicTypeChecker
9
+ def valid?(type:, value:)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ def union_type(types:)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def check!(type:, value:)
18
+ raise TypeError, "Object must be of type(s) #{type}" unless valid?(type: type, value: value)
19
+
20
+ true
21
+ end
22
+
23
+ def format(type:)
24
+ PP.singleline_pp(type, StringIO.new).string
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dialekt
4
+ # DSL extensions
5
+ module Dsl
6
+ # DSL mixins for Class
7
+ module ClassMixins
8
+ def dsl_scalar(name, **options, &block)
9
+ property = Model::ScalarProperty.new(name: name, **options)
10
+ Docile.dsl_eval(property, &block) if block
11
+ property.setup(owner: self)
12
+ property
13
+ end
14
+
15
+ def dsl_map(name, **options, &block)
16
+ property = Model::MapProperty.new(name: name, **options)
17
+ Docile.dsl_eval(property, &block) if block
18
+ property.setup(owner: self)
19
+ property
20
+ end
21
+
22
+ def dsl_set(name, **options, &block)
23
+ property = Model::SetProperty.new(name: name, **options)
24
+ Docile.dsl_eval(property, &block) if block
25
+ property.setup(owner: self)
26
+ property
27
+ end
28
+ end
29
+
30
+ Class.include(ClassMixins)
31
+ end
32
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dialekt
4
+ module Model
5
+ # BaseAccessor base class
6
+ class BasicProperty
7
+ # Property configuration
8
+ class Shape
9
+ attr_reader :name, :type, :factory, :transformer
10
+
11
+ def initialize(name:, type:, factory: nil, transformer: nil)
12
+ @name = name.to_sym
13
+ @type = type
14
+ @factory = factory&.call_adapter
15
+ @transformer = transformer&.call_adapter
16
+ end
17
+ end
18
+
19
+ attr_reader :name
20
+ attr_writer :type
21
+
22
+ def initialize(name:, type: nil, factory: nil, transformer: nil)
23
+ raise ArgumentError, "Name must not be nil" if name.nil?
24
+
25
+ @name = name
26
+ @type = type
27
+
28
+ @variable = :"@#{name}"
29
+ @factory = factory&.call_adapter
30
+ @transformer = transformer&.call_adapter
31
+ end
32
+
33
+ def setup(owner:); end
34
+
35
+ def access_value(shape:, target:, value: EMPTY)
36
+ if value.equal?(EMPTY)
37
+ get_value(shape: shape, target: target)
38
+ else
39
+ set_value(shape: shape, target: target, value: value)
40
+ end
41
+ end
42
+
43
+ def get_value(shape:, target:)
44
+ if target.instance_variable_defined?(@variable)
45
+ target.instance_variable_get(@variable)
46
+ else
47
+ value = shape.factory&.call(object: target)
48
+ target.instance_variable_set(@variable, value)
49
+ end
50
+ end
51
+
52
+ def set_value(shape:, target:, value:)
53
+ if shape.transformer
54
+ begin
55
+ value = shape.transformer.call(object: target, value: value)
56
+ rescue StandardError
57
+ raise TypeError, "Cannot transform value '#{value}' for property #{@name}"
58
+ end
59
+ end
60
+
61
+ type_checker = target.class.dialekt_type_checker
62
+
63
+ begin
64
+ type_checker.check!(type: shape.type, value: value)
65
+ rescue StandardError
66
+ raise TypeError, <<~MSG
67
+ Value '#{value}' (#{value.class}) for property #{@name} must conform to #{type_checker.format(type: shape.type)}
68
+ MSG
69
+ end
70
+
71
+ target.instance_variable_set(@variable, value)
72
+ end
73
+
74
+ def type(type = EMPTY)
75
+ type == EMPTY ? @type : (@type = type)
76
+ end
77
+
78
+ def factory(factory = EMPTY, &block)
79
+ if factory == EMPTY
80
+ if block
81
+ self.factory = block
82
+ else
83
+ @factory
84
+ end
85
+ else
86
+ raise ArgumentError, "Please provide either a factory proc or a block, not both" if block
87
+
88
+ self.factory = factory
89
+ end
90
+ end
91
+
92
+ def factory=(factory)
93
+ @factory = factory&.call_adapter
94
+ end
95
+
96
+ def transformer(transformer = EMPTY, &block)
97
+ if transformer == EMPTY
98
+ if block
99
+ self.transformer = block
100
+ else
101
+ @transformer
102
+ end
103
+ else
104
+ raise ArgumentError, "Please provide either a transformer proc or a block, not both" if block
105
+
106
+ self.transformer = transformer
107
+ end
108
+ end
109
+
110
+ def transformer=(transformer)
111
+ @transformer = transformer&.call_adapter
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docile"
4
+ require "stringio"
5
+
6
+ module Dialekt
7
+ module Model
8
+ # Base class for DSL map accessors
9
+ class MapProperty < BasicProperty
10
+ # Entry configuration
11
+ class Entry
12
+ attr_reader :name, :key_type, :key_transformer, :value_type, :value_factory, :value_transformer
13
+
14
+ def initialize(name:, key_type:, value_type:, key_transformer: nil, value_factory: nil, value_transformer: nil)
15
+ @name = name.to_sym
16
+ @key_type = key_type
17
+ @key_transformer = key_transformer&.call_adapter
18
+ @value_type = value_type
19
+ @value_factory = value_factory&.call_adapter
20
+ @value_transformer = value_transformer&.call_adapter
21
+ end
22
+
23
+ def to_s
24
+ result = StringIO.new
25
+ result << @name << " (" << self.class.base_name << ") {"
26
+ result << "key_type: " << @key_type.to_s
27
+ result << ", key_transformer: " << @key_transformer.source_info if @key_transformer
28
+ result << ", value_type: " << @value_type.to_s
29
+ result << ", value_factory: " << @value_factory.source_info if @value_factory
30
+ result << ", value_transformer: " << @value_transformer.source_info if @value_transformer
31
+ result << "}"
32
+
33
+ result.string
34
+ end
35
+ end
36
+
37
+ def initialize(
38
+ name:,
39
+ key_type: nil,
40
+ value_type: nil,
41
+ type: Hash,
42
+ factory: -> { {} },
43
+ transformer: ->(value:) { value&.to_h }
44
+ )
45
+ super(
46
+ name: name,
47
+ type: type,
48
+ factory: factory,
49
+ transformer: transformer
50
+ )
51
+
52
+ @key_type = key_type
53
+ @key_transformer = nil
54
+
55
+ @value_type = value_type
56
+ @value_transformer = nil
57
+ @value_factory = nil
58
+
59
+ @entries = {}
60
+ end
61
+
62
+ def entries
63
+ @entries.dup.freeze
64
+ end
65
+
66
+ def entries=(entries)
67
+ case entries
68
+ when Hash
69
+ @entries = {}
70
+
71
+ entries.each do |name, entry|
72
+ if name != entry.name
73
+ raise ArgumentError, "Entry key '#{name}' does not match entry name for '#{entry.name}'"
74
+ end
75
+
76
+ define_entry(entry)
77
+ end
78
+ when Enumerable
79
+ @entries = {}
80
+ entries.each { |entry| define_entry(entry) }
81
+ else
82
+ raise ArgumentError, "Entries must be an Enumerable or a Hash"
83
+ end
84
+ end
85
+
86
+ def entry(name, key_type: nil, key_transformer: nil, value_type: nil, value_transformer: nil, value_factory: nil)
87
+ entry = Entry.new(
88
+ name: name.to_sym,
89
+ key_type: key_type || @key_type,
90
+ key_transformer: key_transformer || @key_transformer,
91
+ value_type: value_type || @value_type,
92
+ value_factory: value_factory || @value_factory,
93
+ value_transformer: value_transformer || @value_transformer
94
+ )
95
+
96
+ define_entry(entry)
97
+ end
98
+
99
+ def setup(owner:)
100
+ super
101
+
102
+ property = self
103
+
104
+ if @entries.empty?
105
+ raise StandardError, "Please specify a key type or entries for property '#{@name}'" if @key_type.nil?
106
+ raise StandardError, "Please specify a value type or entries for property '#{@name}'" if @value_type.nil?
107
+
108
+ define_entry(Entry.new(name: owner.dialekt_inflector.singularize(@name), key_type: @key_type, value_type: @value_type))
109
+ end
110
+
111
+ type_checker = owner.class.dialekt_type_checker
112
+
113
+ @key_type ||= type_checker.union_type(types: @entries.values.map(&:key_type))
114
+ @value_type ||= type_checker.union_type(types: @entries.values.map(&:value_type))
115
+
116
+ owner.define_method(@name) do |value = EMPTY, &block|
117
+ value = property.access_value(shape: property.map_shape, target: self, value: value, &block)
118
+ value.dup.freeze
119
+ end
120
+
121
+ owner.define_method(:"#{@name}=") do |value|
122
+ property.set_value(shape: property.map_shape, target: self, value: value)
123
+ end
124
+
125
+ @entries.each_value do |entry|
126
+ owner.define_method(entry.name) do |key, value = EMPTY, &block|
127
+ property.access_entry(entry: entry, target: self, key: key, value: value, &block)
128
+ end
129
+ end
130
+ end
131
+
132
+ def map_shape
133
+ @map_shape ||= BasicProperty::Shape.new(
134
+ name: @name,
135
+ type: @type,
136
+ factory: @factory,
137
+ transformer: @transformer
138
+ )
139
+ end
140
+
141
+ def access_entry(entry:, target:, key:, value: EMPTY, &block)
142
+ value = if value == EMPTY
143
+ get_entry(entry: entry, target: target, key: key)
144
+ else
145
+ set_entry(entry: entry, target: target, key: key, value: value)
146
+ end
147
+
148
+ Docile.dsl_eval(value, &block) if !value.nil? && block
149
+ value
150
+ end
151
+
152
+ def get_entry(entry:, target:, key:)
153
+ map = get_value(shape: map_shape, target: target)
154
+
155
+ if entry.key_transformer
156
+ begin
157
+ key = entry.key_transformer.call(object: target, key: key)
158
+ rescue StandardError
159
+ raise ArgumentError, "Cannot transform key '#{key}' for property '#{@name}' (#{entry.name})"
160
+ end
161
+ end
162
+
163
+ if entry.value_factory
164
+ map.fetch(key) do
165
+ value = begin
166
+ entry.value_factory.call(object: target, key: key)
167
+ rescue StandardError
168
+ raise StandardError, "Cannot create entry for '#{key}' for property '#{@name}' (#{entry.name})"
169
+ end
170
+
171
+ map[key] = value
172
+ end
173
+ else
174
+ map.fetch(key) do
175
+ raise KeyError, "No value for key '#{key}' for property '#{@name}' (#{entry.name})"
176
+ end
177
+ end
178
+ end
179
+
180
+ def set_entry(entry:, target:, key:, value:)
181
+ map = get_value(shape: map_shape, target: target)
182
+ type_checker = target.class.dialekt_type_checker
183
+
184
+ if entry.key_transformer
185
+ begin
186
+ key = entry.key_transformer.call(object: target, key: key)
187
+ rescue StandardError
188
+ raise ArgumentError, "Cannot transform key '#{key}' for property '#{@name}' (#{entry.name})"
189
+ end
190
+ end
191
+
192
+ unless type_checker.valid?(type: entry.key_type, value: key)
193
+ raise TypeError, "Illegal key type '#{key.class}' for property '#{@name}' (#{entry.name})"
194
+ end
195
+
196
+ if entry.value_transformer
197
+ begin
198
+ value = entry.value_transformer.call(object: target, key: key, value: value)
199
+ rescue StandardError
200
+ raise ArgumentError, "Cannot transform value '#{value}' for property '#{@name}' (#{entry.name})"
201
+ end
202
+ end
203
+
204
+ unless type_checker.valid?(type: entry.value_type, value: value)
205
+ raise TypeError, "Illegal value type '#{value.class}' for property '#{@name}' (#{entry.name})"
206
+ end
207
+
208
+ map.store(key, value)
209
+ end
210
+
211
+ def key_type(type = EMPTY)
212
+ type == EMPTY ? @key_type : (@key_type = type)
213
+ end
214
+
215
+ def key_transformer(transformer = EMPTY)
216
+ transformer == EMPTY ? @key_transformer : (@key_transformer = transformer&.call_adapter)
217
+ end
218
+
219
+ def value_type(type = EMPTY)
220
+ type == EMPTY ? @value_type : (@value_type = type)
221
+ end
222
+
223
+ def value_transformer(transformer = EMPTY)
224
+ transformer == EMPTY ? @value_transformer : (@value_transformer = transformer&.call_adapter)
225
+ end
226
+
227
+ def value_factory(factory = EMPTY)
228
+ factory == EMPTY ? @value_factory : (@value_factory = factory&.call_adapter)
229
+ end
230
+
231
+ def to_s
232
+ result = StringIO.new
233
+
234
+ result << @name << " (" << self.class.base_name << ") {"
235
+ result << "type: " << @type
236
+ result << ", key_type: " << @key_type
237
+ result << ", value_type: " << @value_type
238
+ result << ", factory: " << @factory.source_info if @factory
239
+ result << ", transformer: " << @transformer.source_info if @transformer
240
+ result << ", entries: [" << @entries.values.map(&:name).join(", ") << "]"
241
+ result << "}"
242
+
243
+ result.string
244
+ end
245
+
246
+ protected
247
+
248
+ def define_entry(entry)
249
+ raise ArgumentError, "Entry '#{entry.name}' already exists for property '#{@name}'" if @entries.key?(entry.name)
250
+
251
+ @entries[entry.name] = entry
252
+ end
253
+ end
254
+ end
255
+ end