cecil 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.
data/lib/cecil/node.rb ADDED
@@ -0,0 +1,397 @@
1
+ require_relative "content_for"
2
+ require_relative "text"
3
+ require_relative "indentation"
4
+
5
+ module Cecil
6
+ class Node
7
+ # @!visibility private
8
+ attr_accessor :parent
9
+
10
+ # @!visibility private
11
+ def initialize(parent:)
12
+ self.parent = parent
13
+ end
14
+
15
+ # @!visibility private
16
+ def builder = root.builder
17
+
18
+ # @!visibility private
19
+ def root = parent.root
20
+
21
+ # @!visibility private
22
+ def depth = parent.depth + 1
23
+
24
+ # @!visibility private
25
+ def evaluate! = self
26
+
27
+ # @!visibility private
28
+ def replace_child(...) = nil
29
+
30
+ # @!visibility private
31
+ def reattach_to(new_parent)
32
+ parent.remove_child self
33
+
34
+ self.parent = new_parent
35
+ new_parent.add_child self
36
+ end
37
+
38
+ # @!visibility private
39
+ def replace_with(node) = builder.replace_node self, node
40
+
41
+ # Provide values for placeholders and/or nest a block of code. When called, will replace this node with a {Literal}
42
+ # or {LiteralWithChildren}.
43
+ #
44
+ # Placeholder values can be given as positional arguments or named values, but not both.
45
+ #
46
+ # When called with a block, the block is called immediately and any source code emitted is nested under the current
47
+ # block.
48
+ #
49
+ # @return [Node]
50
+ #
51
+ # @overload with(*positional_values)
52
+ # @overload with(**named_values)
53
+ # @overload with(*positional_values, &)
54
+ # @overload with(**named_values, &)
55
+ # @overload with(&)
56
+ #
57
+ # @example Positional values are replaced in the order given
58
+ # `const $field = $value`["user", "Alice".to_json]
59
+ # # const user = "Alice"
60
+ #
61
+ # @example Positional values replace all placeholders with the same name
62
+ # `const $field: $Namespace.$Class = new $Namespace.$Class()`["user", "Models", "User"]
63
+ # # const user: Models.User = new Models.User()
64
+ #
65
+ # @example Named values replace all placeholders with the given name
66
+ # `const $field = $value`[field: "user", value: "Alice".to_json]
67
+ # # const user = "Alice"
68
+ #
69
+ # `const $field: $Class = new $Class()`[field: "user", Class: "User"]
70
+ # # const user: User = new User()
71
+ #
72
+ # @example Blocks indent their emitted contents (see {Code#indent_chars})
73
+ # `class $Class {`["User"] do
74
+ # `public $field: $type`["name", "string"]
75
+ #
76
+ # # multiline nodes still get indented correctly
77
+ # `get id() {
78
+ # return this.name
79
+ # }`
80
+ #
81
+ # # nodes can nest arbitrarily deep
82
+ # `get $field() {`["upperCaseName"] do
83
+ # `return this.name.toUpperCase()`
84
+ # end
85
+ # end
86
+ #
87
+ # # class User {
88
+ # # public name: string
89
+ # # get id() {
90
+ # # return this.name
91
+ # # }
92
+ # # get upperCaseName() {
93
+ # # return this.name.toUpperCase()
94
+ # # }
95
+ # # }
96
+ #
97
+ # @example Blocks close trailing open brackets (defined in {Code#block_ending_pairs})
98
+ # `ids = new Set([`[] do
99
+ # `1, 2, 3`
100
+ # end
101
+ # # ids = new Set([
102
+ # # 1, 2, 3
103
+ # # ])
104
+ #
105
+ # @example Can be called with no parameters to nest a block
106
+ # `ids = new Set([`[] do
107
+ # `1, 2, 3`
108
+ # end
109
+ # @see Code
110
+ def with(*positional_values, **named_values, &) = raise "Not implemented" # rubocop:disable Lint/UnusedMethodArgument
111
+
112
+ # Alias of {#with}
113
+ # @return [Node]
114
+ def [](...)
115
+ # don't use alias/alias_method b/c subclasses overriding `with` need `[]` to call `self.with`
116
+ with(...)
117
+ end
118
+
119
+ # Append a string or node to the node, without making a new line.
120
+ #
121
+ # @param string_or_node [String, Node]
122
+ # @return [Node]
123
+ #
124
+ # @example Append a string to close brackets that aren't closed automatically
125
+ # `test("quacks like a duck", () => {`[] do
126
+ # `expect(duck)`
127
+ # end << ')' # closes open bracket from "test("
128
+ #
129
+ # # test("quacks like a duck", () => {
130
+ # # expect(duck)
131
+ # # })
132
+ #
133
+ # @example Use backticks to append brackets
134
+ # `test("quacks like a duck", () => {`[] do
135
+ # `expect(duck)`
136
+ # end << `)` # closes open bracket from "test("
137
+ #
138
+ # # test("quacks like a duck", () => {`
139
+ # # expect(duck)
140
+ # # })
141
+ def <<(string_or_node)
142
+ SameLineContainer.new(parent:).tap do |container|
143
+ container.add_child self
144
+ replace_with container
145
+ end << string_or_node
146
+ end
147
+
148
+ # @!visibility private
149
+ module AsParent
150
+ def self.included(base)
151
+ base.attr_accessor :children
152
+ end
153
+
154
+ def initialize(**kwargs, &)
155
+ super(**kwargs)
156
+
157
+ self.children = []
158
+ add_to_root(&)
159
+ end
160
+
161
+ def add_to_root(&) = builder.build_node(self, &)
162
+
163
+ def build_child(**kwargs) = root.build_child(**kwargs, parent: self)
164
+
165
+ def add_child(child) = children << child
166
+
167
+ def evaluate!
168
+ children&.map!(&:evaluate!)
169
+ super
170
+ end
171
+
172
+ def remove_child(child) = children.delete(child)
173
+
174
+ def replace_child(old_node, new_node)
175
+ if idx = children.index(old_node)
176
+ children[idx] = new_node
177
+ else
178
+ children.each { _1.replace_child(old_node, new_node) }
179
+ end
180
+ end
181
+
182
+ def stringify_children = children.map(&:stringify)
183
+
184
+ def stringify = stringify_children.join
185
+ end
186
+
187
+ # Node that will be replaced with its children, after the rest of the document is evaluated.
188
+ #
189
+ # Created by calling {BlockContext#defer} or by the internal workings of {BlockContext#content_for}.
190
+ #
191
+ # @see BlockContext#defer
192
+ # @see BlockContext#content_for
193
+ class Deferred < Node
194
+ # @!visibility private
195
+ def initialize(**kwargs, &block) # rubocop:disable Style/ArgumentsForwarding,Naming/BlockForwarding
196
+ super(**kwargs)
197
+
198
+ @evaluate = lambda do
199
+ Container.new(**kwargs, &block) # rubocop:disable Style/ArgumentsForwarding,Naming/BlockForwarding
200
+ .tap { root.replace_child self, _1 }
201
+ end
202
+ end
203
+
204
+ # @!visibility private
205
+ def evaluate!(...) = @evaluate.call(...)
206
+ end
207
+
208
+ # @!visibility private
209
+ class RootNode < Node
210
+ include AsParent
211
+
212
+ attr_accessor :builder
213
+
214
+ def initialize(builder)
215
+ @builder = builder
216
+
217
+ super(parent: nil)
218
+ end
219
+
220
+ def root = self
221
+ def depth = -1
222
+
223
+ def add_to_root(...) = nil
224
+
225
+ def build_node(...) = builder.build_node(...)
226
+
227
+ def build_child(src:, parent: self) = Template.build(src:, parent:, builder:)
228
+ end
229
+
230
+ # @!visibility private
231
+ class Container < Node
232
+ include AsParent
233
+
234
+ def depth = parent.depth
235
+ end
236
+
237
+ # Node that contains child nodes that were appended to via {Node#<<}.
238
+ class SameLineContainer < Container
239
+ # @!visibility private
240
+ def initialize(parent:)
241
+ super(parent:) do
242
+ yield self if block_given?
243
+ end
244
+ end
245
+
246
+ # @!visibility private
247
+ def stringify
248
+ *firsts, last = stringify_children
249
+ firsts_without_trailing_newline = firsts.map { _1.sub(/\R\z/m, "") }
250
+ [*firsts_without_trailing_newline, last].join
251
+ end
252
+
253
+ # @!visibility private
254
+ def <<(string_or_node)
255
+ case string_or_node
256
+ in Node => node
257
+ node.reattach_to self
258
+ in String => string
259
+ builder.build_node(self) { builder.src string }
260
+ end
261
+
262
+ self
263
+ end
264
+ end
265
+
266
+ # Node that will be inserted in another location in the document.
267
+ #
268
+ # Created by {BlockContext#content_for} with a block.
269
+ #
270
+ # @see BlockContext#content_for
271
+ class Detached < Container
272
+ # @!visibility private
273
+ attr_accessor :root
274
+
275
+ # @!visibility private
276
+ def initialize(root, &)
277
+ @root = root
278
+ super(parent: nil, &)
279
+ end
280
+
281
+ # @!visibility private
282
+ def attach_to(new_parent)
283
+ self.parent = new_parent
284
+ new_parent.add_child self
285
+ end
286
+ end
287
+
288
+ # Node with source code, no placeholders, and no child nodes. Created by calling
289
+ # {BlockContext#src `` #`(code_str) ``} with a string that has no placeholders.
290
+ #
291
+ # Will not accept any placeholder values, but can receive children via {#with}/{#[]} and will replace itself with a
292
+ # {LiteralWithChildren}.
293
+ class Literal < Node
294
+ # @!visibility private
295
+ def self.build(...)
296
+ klass = block_given? ? LiteralWithChildren : self
297
+ klass.new(...)
298
+ end
299
+
300
+ # @!visibility private
301
+ def initialize(src:, **kwargs)
302
+ super(**kwargs)
303
+ @src = src
304
+ end
305
+
306
+ # @!visibility private
307
+ def with(*args, **options, &)
308
+ raise "This fragement has no placeholders. Fragment:\n#{@src}" if args.any? || options.any?
309
+
310
+ raise "This method requires a block" unless block_given?
311
+
312
+ self.class.build(src: @src, parent:, &)
313
+ .tap { builder.replace_node self, _1 }
314
+ end
315
+
316
+ # @!visibility private
317
+ def stringify_src
318
+ src = Indentation.reindent(@src, depth, builder.syntax.indent_chars,
319
+ handle_ambiguity: builder.syntax.handle_ambiguous_indentation)
320
+ src += "\n" unless src.end_with?("\n")
321
+ src
322
+ end
323
+
324
+ # @!visibility private
325
+ alias stringify stringify_src
326
+ end
327
+
328
+ # Node with source code, no placeholders, and child nodes. Created by calling {BlockContext#src `` #`(code_str) ``}
329
+ # with a string without placeholders and then calling {#with}/{#[]} on it.
330
+ class LiteralWithChildren < Literal
331
+ include AsParent
332
+
333
+ # @!visibility private
334
+ def closers
335
+ closing_brackets = Text.closers(@src.strip, builder.syntax.block_ending_pairs).to_a
336
+
337
+ Indentation.reindent("#{closing_brackets.join.strip}\n", depth, builder.syntax.indent_chars,
338
+ handle_ambiguity: builder.syntax.handle_ambiguous_indentation)
339
+ end
340
+
341
+ # @!visibility private
342
+ def stringify
343
+ [
344
+ stringify_src,
345
+ *stringify_children,
346
+ *closers
347
+ ].join
348
+ end
349
+ end
350
+
351
+ # A node that has placeholders but does not yet have values or children. Created with backticks or
352
+ # {BlockContext#src `` #`(code_str) ``}
353
+ #
354
+ # When {#with}/{#[]} is called on the node, it will replace itself with a {Literal} or {LiteralWithChildren}
355
+ class Template < Node
356
+ # @!visibility private
357
+ def self.build(src:, builder:, **kwargs)
358
+ placeholders = builder.syntax.scan_for_placeholders(src)
359
+
360
+ if placeholders.any?
361
+ new(src:, placeholders:, **kwargs)
362
+ else
363
+ Literal.new(src:, **kwargs)
364
+ end
365
+ end
366
+
367
+ # @!visibility private
368
+ def initialize(src:, placeholders:, **kwargs)
369
+ super(**kwargs)
370
+ @src = src
371
+ @placeholders = placeholders
372
+ end
373
+
374
+ # @see Node#with
375
+ def with(*positional_values, **named_values, &)
376
+ src =
377
+ case [positional_values, named_values, @placeholders]
378
+ in [], {}, []
379
+ @src
380
+ in [], _, _
381
+ Text.interpolate_named(@src, @placeholders, named_values)
382
+ in _, {}, _
383
+ Text.interpolate_positional(@src, @placeholders, positional_values)
384
+ else
385
+ raise "Method expects to be called with either named arguments or positional arguments but not both"
386
+ end
387
+
388
+ Literal
389
+ .build(src:, parent:, &)
390
+ .tap { builder.replace_node self, _1 }
391
+ end
392
+
393
+ # @!visibility private
394
+ def stringify = raise "This fragement has placeholders but was not given values. Fragment:\n#{@src}"
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,31 @@
1
+ module Cecil
2
+ # Represents the name and location of a placeholder in a string.
3
+ Placeholder = Struct.new(:ident, :offset_start, :offset_end) do
4
+ # @!attribute ident
5
+ # @return [String] the name of this placeholder. E.g. the `ident` of `${my_field}` would be `my_field`
6
+
7
+ # @!attribute offset_start
8
+ # @return [Integer] the offset where this placeholder starts in the
9
+ # string. This number is usually taken from a Regexp match.
10
+
11
+ # @!attribute offset_end
12
+ # @return [Integer] the offset where this placeholder ends in the
13
+ # string. This number is usually taken from a Regexp match.
14
+
15
+ # Return the range that this placeholder occupies in the string
16
+ # @return [Range(Integer)]
17
+ def range = offset_start...offset_end
18
+
19
+ # Mimicks Data#with, introduced in Ruby 3.2
20
+ def with(**kwargs) = self.class.new(*to_h.merge(kwargs).values_at(*members))
21
+
22
+ # Create a new {Placeholder} with one member transformed by the given block
23
+ #
24
+ # @example Make a new placeholder with ident in uppercase
25
+ # placeholder.transform_key(:ident, &:upcase)
26
+ #
27
+ # @param [Symbol] member
28
+ # @return [Placeholder]
29
+ def transform_key(member) = with(**{ member => yield(self[member]) })
30
+ end
31
+ end
data/lib/cecil/text.rb ADDED
@@ -0,0 +1,112 @@
1
+ require "set"
2
+
3
+ module Cecil
4
+ # Helper methods for string searching, manipulating, etc.
5
+ module Text
6
+ module_function
7
+
8
+ # Scan a string for matches on a Regexp and return each MatchObject
9
+ #
10
+ # @param str [String] the string to scan
11
+ # @param regexp [Regexp] the regexp to scan with
12
+ # @return [Array<MatchData>] The MatchData objects for each instance of the regexp matched in the string
13
+ def scan_for_re_matches(str, regexp) = str.to_enum(:scan, regexp).map { Regexp.last_match }
14
+
15
+ # Interpolate positional placeholder values into a string
16
+ #
17
+ # @param template [String]
18
+ # @param placeholders [Array<Placeholder>]
19
+ # @param values [Array<#to_s>]
20
+ # @return [String] `template`, except with placeholders replaced with
21
+ # provided values
22
+ def interpolate_positional(template, placeholders, values)
23
+ match_idents = placeholders.to_set(&:ident)
24
+
25
+ if match_idents.size != values.size
26
+ raise "Mismatch between number of placeholders (#{match_idents.size}) and given values (#{values.size})"
27
+ end
28
+
29
+ replace(template, placeholders, match_idents.zip(values).to_h)
30
+ end
31
+
32
+ # Interpolate named placeholder values into a string
33
+ #
34
+ # @param template [String]
35
+ # @param placeholders [Array<Placeholder>]
36
+ # @param idents_to_values [Hash{#to_s=>#to_s}]
37
+ # @return [String] `template`, except with placeholders replaced with
38
+ # provided values
39
+ def interpolate_named(template, placeholders, idents_to_values)
40
+ duplicated_keys = idents_to_values.keys.group_by(&:to_s).values.select { _1.size > 1 }
41
+ if duplicated_keys.any?
42
+ keys_list = duplicated_keys.map { "\n - #{_1.map(&:inspect).join(", ")}\n" }.join
43
+ raise "Duplicate placeholder value keys:#{keys_list}"
44
+ end
45
+
46
+ values_idents = idents_to_values.keys.to_set(&:to_s)
47
+ match_idents = placeholders.to_set(&:ident)
48
+
49
+ if match_idents != values_idents
50
+ missing_values = match_idents - values_idents
51
+ extra_values = values_idents - match_idents
52
+ message = "Mismatch between placeholders and provide values."
53
+ message << "\n Missing values for placeholders #{missing_values.join(", ")}" if missing_values.any?
54
+ message << "\n Missing placeholders for values #{extra_values.join(", ")}" if extra_values.any?
55
+
56
+ raise message
57
+ end
58
+
59
+ replace(template, placeholders, idents_to_values)
60
+ end
61
+
62
+ # Replace placeholders in the string with provided values
63
+ #
64
+ # @param template [String]
65
+ # @param placeholders [Array<Placeholder>]
66
+ # @param idents_to_values [Hash{#to_s=>#to_s}]
67
+ # @return [String] `template`, except with placeholders replaced with
68
+ # provided values
69
+ def replace(template, placeholders, idents_to_values)
70
+ values = idents_to_values.transform_keys(&:to_s)
71
+
72
+ template.dup.tap do |new_src|
73
+ placeholders.reverse.each do |placeholder|
74
+ value = values.fetch(placeholder.ident)
75
+
76
+ new_src[placeholder.range] = value.to_s
77
+ end
78
+ end
79
+ end
80
+
81
+ # Returns any closing bracket found
82
+ #
83
+ # @param src [String]
84
+ # @param block_ending_pairs [Hash{String=>String}]
85
+ def match_ending_pair(src, block_ending_pairs)
86
+ return if src.empty?
87
+
88
+ block_ending_pairs.detect { |opener, _closer| src.end_with?(opener) }
89
+ end
90
+
91
+ # Returns or yields each closing bracket.
92
+ #
93
+ # @param src [String]
94
+ # @param block_ending_pairs [Hash{String=>String}]
95
+ #
96
+ # @overload closers(src, block_ending_pairs, &)
97
+ # With block given, behaves like {.each_closer}
98
+ # @yield [String]
99
+ #
100
+ # @overload closers(src, block_ending_pairs)
101
+ # When no block is given, returns an enumerator of the {.each_closer} method
102
+ # @return [Enumerator<String>]
103
+ def closers(src, block_ending_pairs)
104
+ return enum_for(:closers, src, block_ending_pairs) unless block_given?
105
+
106
+ while match_ending_pair(src, block_ending_pairs) in [opener, closer]
107
+ yield closer
108
+ src = src[0...-opener.size]
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cecil
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cecil.rb ADDED
@@ -0,0 +1,11 @@
1
+ require_relative "cecil/version"
2
+ require_relative "cecil/builder"
3
+ require_relative "cecil/block_context"
4
+ require_relative "cecil/code"
5
+
6
+ module Cecil
7
+ # @!visibility private
8
+ def self.generate(out:, syntax_class:, &)
9
+ out << Builder.new(syntax_class).build(&)
10
+ end
11
+ end
data/sig/cecil.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Cecil
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cecil
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike Nicholaides
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-01-21 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Cecil templates look like the source code you want to generate thanks
14
+ to Ruby's flexible syntax.
15
+ email:
16
+ - mike@nicholaides.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - ".rspec"
22
+ - ".rubocop.yml"
23
+ - ".tool-versions"
24
+ - ".yard/README.md"
25
+ - ".yardopts"
26
+ - Gemfile
27
+ - Gemfile.lock
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - lib/cecil.rb
32
+ - lib/cecil/block_context.rb
33
+ - lib/cecil/builder.rb
34
+ - lib/cecil/code.rb
35
+ - lib/cecil/content_for.rb
36
+ - lib/cecil/indentation.rb
37
+ - lib/cecil/lang/typescript.rb
38
+ - lib/cecil/node.rb
39
+ - lib/cecil/placeholder.rb
40
+ - lib/cecil/text.rb
41
+ - lib/cecil/version.rb
42
+ - sig/cecil.rbs
43
+ homepage: https://github.com/nicholaides/cecil
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ allowed_push_host: https://rubygems.org
48
+ rubygems_mfa_required: 'true'
49
+ homepage_uri: https://github.com/nicholaides/cecil
50
+ source_code_uri: https://github.com/nicholaides/cecil
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.1.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.3.26
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: An experimental templating library for generating source code.
70
+ test_files: []