cecil 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []