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.
@@ -0,0 +1,66 @@
1
+ require_relative "content_for"
2
+ require_relative "node"
3
+
4
+ require "forwardable"
5
+
6
+ module Cecil
7
+ # @!visibility private
8
+ class Builder
9
+ attr_accessor :root, :syntax
10
+
11
+ extend Forwardable
12
+ def_delegators :@content_for, :content_for, :content_for!, :content_for?
13
+
14
+ def initialize(syntax_class)
15
+ @syntax = syntax_class.new
16
+ @helpers = syntax_class::Helpers
17
+
18
+ @root = Node::RootNode.new(self)
19
+ @active_nodes = [@root]
20
+
21
+ @content_for = ContentFor.new(
22
+ store: method(:detached_node),
23
+ place: method(:reattach_nodes),
24
+ defer: method(:defer)
25
+ )
26
+ end
27
+
28
+ def build(&block)
29
+ @block_context = BlockContext.new(block.binding.receiver, self, @helpers)
30
+ @block_context.instance_exec(&block)
31
+
32
+ root
33
+ .evaluate!
34
+ .stringify
35
+ .lstrip
36
+ end
37
+
38
+ def detached_node(&) = Node::Detached.new(root, &)
39
+
40
+ def reattach_nodes(detached_nodes)
41
+ container = Node::Container.new(parent: current_node) {} # rubocop:disable Lint/EmptyBlock
42
+ detached_nodes.each { _1.attach_to container }
43
+ add_node container
44
+ container
45
+ end
46
+
47
+ def current_node = @active_nodes.last || raise("Not inside a Cecil block")
48
+ def replace_node(...) = current_node.replace_child(...)
49
+
50
+ def build_node(node)
51
+ @active_nodes.push node
52
+ yield
53
+ ensure
54
+ @active_nodes.pop
55
+ end
56
+
57
+ def src(src) = add_node current_node.build_child(src:)
58
+
59
+ def defer(&) = add_node Node::Deferred.new(parent: current_node, &)
60
+
61
+ def add_node(child)
62
+ current_node.add_child child
63
+ child
64
+ end
65
+ end
66
+ end
data/lib/cecil/code.rb ADDED
@@ -0,0 +1,335 @@
1
+ require_relative "placeholder"
2
+ require_relative "indentation"
3
+
4
+ module Cecil
5
+ # {Code} serves as the base class for generating source code using Cecil.
6
+ # Subclassing {Code} allows customizing the behavior (indentation, auto-closing brackets, etc) and providing helpers.
7
+ #
8
+ # - Override {Code} instance methods to change behavior.
9
+ # - Defined a module named `Helpers` in your subclass to add methods available in the Cecil block.
10
+ #
11
+ # Check out classes in the {Lang} module for examples of customizing {Code}.
12
+ #
13
+ # @example Creating a custom syntax
14
+ # class CSS < Cecil::Code
15
+ # # Override instance methods to customize behavior
16
+ #
17
+ # def indent_chars = " " # use 2 spaces for indentation
18
+ #
19
+ # # methods in this module will be available in a Cecil block
20
+ # module Helpers
21
+ #
22
+ # # if we want to inherit other helpers, include the module
23
+ # include Cecil::Code::Helpers
24
+ #
25
+ # def data_uri(file) = DataURI.from_file(file) # fake code
26
+ # end
27
+ # end
28
+ #
29
+ # background_types = {
30
+ # star: "star-@100x100.png",
31
+ # dots: "polka-dots@50x50.png",
32
+ # }
33
+ #
34
+ # CSS.generate_string do
35
+ # background_types.each do |bg_name, image_file|
36
+ # `.bg-$class {`[bg_name] do
37
+ #
38
+ # # #data_uri is available because it was defined in CSS::Helpers
39
+ # `background-image: url($img);`[data_uri(image_file)]
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # # outputs:
45
+ # # .bg-star {
46
+ # # background-image: url(data:image/png;base64,iRxVB0…);
47
+ # # }
48
+ # # .bg-dots {
49
+ # # background-image: url(data:image/png;base64,iRxVB0…);
50
+ # # }
51
+
52
+ class Code
53
+ class << self
54
+ # Generates output by executing the given block and writing its return value to the provided output buffer/stream.
55
+ #
56
+ # The stream is written to by calling `#<<` with the generated source code.
57
+ #
58
+ # @param [#<<] out The output buffer/stream to write to
59
+ # @yield The given block can use backticks (i.e. {BlockContext#src `` #`(code_str) ``} ) to add lines of code to
60
+ # the buffer/stream.
61
+ # @return The returned value of `out <<`
62
+ #
63
+ # @example Outputing to stdout
64
+ # Cecil.generate do
65
+ # `function helloWorld() {}`
66
+ # end
67
+ #
68
+ # @example Outputing to a file
69
+ # File.open "output.js", "w" do |file|
70
+ # Cecil.generate file do
71
+ # `function helloWorld() {}`
72
+ # end
73
+ # end
74
+ def generate(out = $stdout, &) = Cecil.generate(syntax_class: self, out:, &)
75
+
76
+ # Generates output and returns it as a string
77
+ #
78
+ # @yield (see .generate)
79
+ # @return [String] The generated source code
80
+ # @see .generate
81
+ # @example
82
+ # my_code = Cecil.generate_string do
83
+ # `function helloWorld() {}`
84
+ # end
85
+ # puts my_code
86
+ def generate_string(&) = generate("", &)
87
+ end
88
+
89
+ # Subclasses of {Code} can define a module named `Helpers` and add methods to it that will be available inside a
90
+ # Cecil block for that subclass.
91
+ #
92
+ # When defining your own `Helpers` module, if you want your parent class' helper methods, then `include` your parent
93
+ # class' `Helpers` module in yours, like this:
94
+ #
95
+ # class CSS < Code::Syntax
96
+ # module Helpers
97
+ # include Code::Syntax::Helpers
98
+ #
99
+ # def data_uri(file) = DataURI.from_file(file) # this is made up, not working code
100
+ #
101
+ # end
102
+ # end
103
+ #
104
+ # class SCSS < CSS
105
+ # module Helpers
106
+ # include CSS::Helpers # now `data_uri` will be an available helper
107
+ # end
108
+ # end
109
+ #
110
+ # Subclasses that don't define a `Helpers` module inherit the one from their parent class.
111
+ module Helpers
112
+ end
113
+
114
+ # Returns the string to use for each level of indentation. Default is 4 spaces.
115
+ #
116
+ # To turn off indentation, override this method to return an empty string.
117
+ #
118
+ # @return [String] the string to use for each level of indentation. Default is 4 spaces.
119
+ #
120
+ # @example Use tab for indentation
121
+ # class MySyntax < Cecil::Code
122
+ # def indent_chars = "\t"
123
+ # end
124
+ #
125
+ # @example Use 2 spaces for indentation
126
+ # class MySyntax < Cecil::Code
127
+ # def indent_chars = " "
128
+ # end
129
+ def indent_chars = " "
130
+
131
+ # When indenting with a code block, the end of the code string is searched for consecutive opening brackets, each of
132
+ # which gets closed with a matching closing bracket.
133
+ #
134
+ # E.g.
135
+ #
136
+ # `my_func( (<`[] do
137
+ # `more code`
138
+ # end
139
+ # # outputs:
140
+ # # my_func((<
141
+ # # more code
142
+ # # >) )
143
+ #
144
+ # @return [Hash{String => String}] Pairs of opening/closing strings
145
+ #
146
+ # @example Override to close only `{` and `[` brackets.
147
+ # class MySyntax < Cecil::Code
148
+ # def block_ending_pairs
149
+ # {
150
+ # "{" => "}",
151
+ # "[" => "]",
152
+ #
153
+ # " " => " ", # allows for "my_func { [ " to be closed with " ] } "
154
+ # "\t" => "\t" # allows for "my_func\t{[\t" to be closed with "\t]}\t"
155
+ # }
156
+ # end
157
+ # end
158
+ #
159
+ # @example Override to also close `/*` with `*/`
160
+ # class MySyntax < Cecil::Code
161
+ # def block_ending_pairs = super.merge({ '/*' => '*/' })
162
+ # end
163
+ #
164
+ # @example Override to turn this feature off, and don't close open brackets
165
+ # class MySyntax < Cecil::Code
166
+ # def block_ending_pairs = {}
167
+ # end
168
+ def block_ending_pairs
169
+ {
170
+ "{" => "}",
171
+ "[" => "]",
172
+ "<" => ">",
173
+ "(" => ")",
174
+
175
+ " " => " ", # allows for "my_func ( [ " to be closed with " ] ) "
176
+ "\t" => "\t" # allows for "my_func\t([\t" to be closed with "\t])\t"
177
+ }
178
+ end
179
+
180
+ # Pairs that can be used to surround placeholder names. The pairs that are used do not change the placeholder's
181
+ # name.
182
+ #
183
+ # E.g., these all produce the same result:
184
+ #
185
+ # `const $field`[field: 'username']
186
+ # `const ${field}`[field: 'username']
187
+ # `const $[field]`[field: 'username']
188
+ # `const $<field>`[field: 'username']
189
+ # `const $(field)`[field: 'username']
190
+ #
191
+ # By default, `"" => ""` is one of the pairs, meaning you don't need to surround placeholder names.
192
+ #
193
+ # @return [Regexp]
194
+ #
195
+ # @example Override to allow `$/my_field/` syntax for placeholders, in addition to the default options
196
+ # class MySyntax < Cecil::Code
197
+ # def placeholder_delimiting_pairs = super.merge("/" => "/")
198
+ # end
199
+ #
200
+ # @example Override to turn off placeholder delimiting pairs (i.e. only allow `$my_field` syntax)
201
+ # class MySyntax < Cecil::Code
202
+ # def placeholder_delimiting_pairs = { "" => "" }
203
+ # # or
204
+ # def placeholder_delimiting_pairs = Cecil::Code::PLACEHOLDER_NO_BRACKETS_PAIR
205
+ # end
206
+ def placeholder_delimiting_pairs
207
+ {
208
+ "{" => "}",
209
+ "[" => "]",
210
+ "<" => ">",
211
+ "(" => ")",
212
+ **PLACEHOLDER_NO_BRACKETS_PAIR # this needs to be last
213
+ }
214
+ end
215
+ PLACEHOLDER_NO_BRACKETS_PAIR = { "" => "" }.freeze
216
+
217
+ # Regexp to use to match a placeholder's name.
218
+ #
219
+ # @return [Regexp]
220
+ #
221
+ # @example Override to only allow all-caps placeholders (e.g. `$MY_FIELD`)
222
+ # class MySyntax < Cecil::Code
223
+ # def placeholder_ident_re = /[A-Z_]+/
224
+ # end
225
+ #
226
+ # @example Override to allow any characters placeholders, and require brackets (e.g. `${ my field ??! :) }`)
227
+ # class MySyntax < Cecil::Code
228
+ # # override `#placeholder_delimiting_pairs` to allow the default
229
+ # # brackets but not allow no brackets
230
+ # def placeholder_delimiting_pairs = super.except("")
231
+ # def placeholder_ident_re = /.+/
232
+ # end
233
+ def placeholder_ident_re = /[[:alnum:]_]+/
234
+
235
+ # Regexp to match a placeholder's starting character(s).
236
+ #
237
+ # @return [Regexp]
238
+ #
239
+ # @example Override to make placeholders start with `%`, e.g. `%myField`
240
+ # class MySyntax < Cecil::Code
241
+ # def placeholder_start_re = /%/
242
+ # end
243
+ #
244
+ # @example Override to make placeholders be all-caps without starting characters (e.g. `MY_FIELD`)
245
+ # class MySyntax < Cecil::Code
246
+ # def placeholder_start_re = //
247
+ # end
248
+ def placeholder_start_re = /\$/
249
+
250
+ # Regexp to match placeholders. By default, this constructs a Regexp from the pieces defined in:
251
+ #
252
+ # - {#placeholder_delimiting_pairs}
253
+ # - {#placeholder_ident_re}
254
+ # - {#placeholder_start_re}
255
+ #
256
+ # If you override this method, make sure it returns a Regexp that has a capture group named "placeholder".
257
+ #
258
+ # @return [Regexp] A regexp with a capture group named "placeholder"
259
+ def placeholder_re
260
+ /
261
+ #{placeholder_start_re}
262
+ #{Regexp.union(
263
+ placeholder_delimiting_pairs.map do |pstart, pend|
264
+ /
265
+ #{Regexp.quote pstart}
266
+ (?<placeholder>#{placeholder_ident_re})
267
+ #{Regexp.quote pend}
268
+ /x
269
+ end
270
+ )}
271
+ /x
272
+ end
273
+
274
+ # Returns a list of {Placeholder} objects representing placeholders found in the given string. The default
275
+ # implementation scans the string for matches of {#placeholder_re}.
276
+ #
277
+ # This method can be overriden to change the way placeholders are parsed, or to omit, add, or modify placeholders.
278
+ #
279
+ # @return [Array<Placeholder>]
280
+ #
281
+ # @example Override to transform placeholder names to lowercase
282
+ # class MySyntax < Cecil::Code
283
+ # super.map do |placeholder|
284
+ # placeholder.transform_key(:ident, &:downcase)
285
+ # end
286
+ # end
287
+ #
288
+ # MySyntax.generate_string do
289
+ # `const $VAR = $VALUE`[var: 'id', value: '42']
290
+ # end
291
+ # # outputs:
292
+ # # const id = 42
293
+ def scan_for_placeholders(src)
294
+ Text.scan_for_re_matches(src, placeholder_re)
295
+ .map do |match|
296
+ Placeholder.new(match[:placeholder], *match.offset(0))
297
+ end
298
+ end
299
+
300
+ # What do to in case of ambiguous indentation.
301
+ #
302
+ # 2 examples of ambiguous indentation:
303
+ #
304
+ # `def python_fn():
305
+ # pass`
306
+ #
307
+ # `def ruby_method
308
+ # end`
309
+ #
310
+ # Because only the second line strings have leading indentation, we don't know how `pass` or `end` should be
311
+ # indented.
312
+ #
313
+ # In the future we could use `caller` to identify the source location of that line and read the ruby file to figure
314
+ # out the indentation.
315
+ #
316
+ # For now, though, you can return:
317
+ #
318
+ # - {Indentation::Ambiguity.raise_error}
319
+ # - {Indentation::Ambiguity.ignore} (works for the Ruby example)
320
+ # - {Indentation::Ambiguity.adjust_by} (works for the Python example)
321
+ #
322
+ # @example Override to ignore ambiguous indentation
323
+ # class MyRubySyntax < Cecil::Code
324
+ # def handle_ambiguous_indentation = Indentation::Ambiguity.ignore
325
+ # end
326
+ #
327
+ # @example Override to adjust indentation
328
+ # class MyRubySyntax < Cecil::Code
329
+ # def handle_ambiguous_indentation
330
+ # Indentation::Ambiguity.adjust_by(2)
331
+ # end
332
+ # end
333
+ def handle_ambiguous_indentation = Indentation::Ambiguity.raise_error
334
+ end
335
+ end
@@ -0,0 +1,27 @@
1
+ module Cecil
2
+ # @!visibility private
3
+ class ContentFor
4
+ def initialize(store:, place:, defer:)
5
+ @store = store
6
+ @place = place
7
+ @defer = defer
8
+
9
+ @content = Hash.new { |hash, key| hash[key] = [] }
10
+ end
11
+
12
+ def content_for(key, &)
13
+ if block_given?
14
+ @content[key] << @store.call(&)
15
+ nil # so that users don't get access to the array of content
16
+ elsif content_for?(key)
17
+ content_for!(key)
18
+ else
19
+ @defer.call { content_for!(key) }
20
+ end
21
+ end
22
+
23
+ def content_for?(key) = @content.key?(key)
24
+
25
+ def content_for!(key) = @place.call(@content.fetch(key))
26
+ end
27
+ end
@@ -0,0 +1,131 @@
1
+ module Cecil
2
+ module Indentation
3
+ module_function
4
+
5
+ # @!visibility private
6
+ def line_level(str) = str.index(/[^\t ]/) || str.length
7
+
8
+ # @!visibility private
9
+ def levels(lines) = lines.map { line_level(_1) }
10
+
11
+ # @!visibility private
12
+ def level__basic(src) = levels(src.lines.grep(/\S/)).min
13
+
14
+ module Ambiguity
15
+ module_function
16
+
17
+ # When given an ambiguously indented string, it assumes that first line is `adjustment` characters less than the
18
+ # least indented of the other lines.
19
+ #
20
+ # Useful for this situation. Setting to `adjust_by(4)` will behave
21
+ # according to what's intended.
22
+ #
23
+ # `def python_fn():
24
+ # pass`
25
+ def adjust_by(adjustment) = ->(min_level:, **) { min_level - adjustment }
26
+
27
+ # When given an ambiguously indented string, assume that first line is the same as the least indented of the other
28
+ # lines.
29
+ #
30
+ # Useful for this situation:
31
+ #
32
+ # `def ruby_method
33
+ # end`
34
+ def ignore = adjust_by(0)
35
+
36
+ # When given an ambiguously indented string, raise an exception
37
+ def raise_error
38
+ lambda do |src:, **|
39
+ raise <<~MSG
40
+ Indentation is ambiguous, cannot reindent. Try adding a blank
41
+ line at the beginning or end of this fragment. Fragment:
42
+
43
+ #{src}
44
+ MSG
45
+ end
46
+ end
47
+ end
48
+
49
+ # @!visibility private
50
+ def level__starts_and_stops_with_content(src, handle_ambiguity:)
51
+ levels = levels(src.lines.drop(1).grep(/\S/))
52
+
53
+ min_level = levels.min
54
+
55
+ if levels.last == levels.max && ambiguous_level = handle_ambiguity.call(src:, min_level:)
56
+ return ambiguous_level
57
+ end
58
+
59
+ min_level
60
+ end
61
+
62
+ # @!visibility private
63
+ def level__starts_with_content(src)
64
+ src.lines => _first, *middle, last
65
+
66
+ levels([*middle.grep(/\S/), last]).min
67
+ end
68
+
69
+ # @!visibility private
70
+ SINGLE_LINE = /\A.*\n?\z/
71
+
72
+ # @!visibility private
73
+ STARTS_WITH_CONTENT = /\A\S/ # e.g. `content ...
74
+
75
+ # @!visibility private
76
+ ENDS_WITH_CONTENT = /.*\S.*\z/ # e.g. "..\n content "
77
+
78
+ # @!visibility private
79
+ def level(src, handle_ambiguity:)
80
+ case src
81
+ when SINGLE_LINE
82
+ 0
83
+ when STARTS_WITH_CONTENT
84
+ if src =~ ENDS_WITH_CONTENT
85
+ level__starts_and_stops_with_content(src, handle_ambiguity:)
86
+ else
87
+ level__starts_with_content(src)
88
+ end
89
+ else
90
+ level__basic(src)
91
+ end
92
+ end
93
+
94
+ # Reindent `src` string to the level specified by `depth`. `indent_chars` is used only the current level of
95
+ # indentation as well as add more indentation.
96
+ #
97
+ # Reindents the given source code string to the specified depth.
98
+ #
99
+ # @param src [String] The source code to reindent
100
+ # @param depth [Integer] The indentation level to reindent to
101
+ # @param indent_chars [String] The indentation characters to use
102
+ # @param handle_ambiguity [Proc] How to handle ambiguous indentation cases.
103
+ #
104
+ # - defaults to {Ambiguity.raise_error}
105
+ # - use {Ambiguity.ignore} if your syntax doesn't have signigicant whitespace
106
+ # - use {Ambiguity.adjust_by} if your syntax has significant whitespace
107
+ def reindent(src, depth, indent_chars, handle_ambiguity: Ambiguity.raise_error)
108
+ # Turn
109
+ # "\n" +
110
+ # " line 1\n" +
111
+ # " line 2\n"
112
+ # into
113
+ # " line 1\n" +
114
+ # " line 2\n"
115
+ src = src.sub(/\A\R/m, "")
116
+
117
+ new_indentation = indent_chars * depth
118
+ reindent_line_re = /^[ \t]{0,#{level(src, handle_ambiguity:)}}/
119
+
120
+ lines = src.lines.map do |line|
121
+ if line =~ /\S/
122
+ line.sub(reindent_line_re, new_indentation)
123
+ else
124
+ line.sub(/^[ \t]*/, "")
125
+ end
126
+ end
127
+
128
+ lines.join
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,95 @@
1
+ require_relative "../../cecil"
2
+ require "json"
3
+
4
+ module Cecil
5
+ module Lang
6
+ class TypeScript < Code
7
+ # Overrides to use 2 spaces for indentation
8
+ def indent_chars = " "
9
+
10
+ # Overrides to ignore ambiguous indentation
11
+ def handle_ambiguous_indentation = Indentation::Ambiguity.ignore
12
+
13
+ # Overrides to add support for closing multi-line comments (e.g. /* ... */)
14
+ def block_ending_pairs = super.merge({ "/*" => "*/" })
15
+
16
+ module Helpers
17
+ include Code::Helpers
18
+
19
+ # Short for "types"; Accepts one or a list of types and returns their union.
20
+ #
21
+ # @example
22
+ # the_types = ["Websocket", "undefined", "null"]
23
+ # `function register<$types>() {}`[t the_types]
24
+ #
25
+ # # outputs:
26
+ # # function register<Websocket | undefined | null>() {}
27
+ #
28
+ # @param items [Array[#to_s], #to_s] One or a list of objects that respond to `#to_s`
29
+ # @return [String] The stringified inputs concatenated with `" | "`
30
+ def t(items) = Array(items).compact.join(" | ")
31
+
32
+ # Short for "list"; Accepts one or a list of strings and returns them joined with `", "`
33
+ #
34
+ # Useful for:
35
+ # - arrays
36
+ # - objects
37
+ # - function arguments
38
+ #
39
+ # @example
40
+ # the_classes = ["Websocket", "Array", "Function"]
41
+ # `register($args)`[l the_classes]
42
+ #
43
+ # # outputs:
44
+ # # register(Websocket, Array, Function)
45
+ #
46
+ # @param items [Array[#to_s], #to_s] One or a list of objects that respond to `#to_s`
47
+ # @return [String] The stringified inputs concatenated with `", "`
48
+ def l(items) = Array(items).compact.join(", ")
49
+
50
+ # Short for "json"; returns the JSON representation of the input.
51
+ #
52
+ # Useful for when you have a value in Ruby and you want it as a literal
53
+ # value in the JavaScript/TypeScript source code.
54
+ #
55
+ # @example
56
+ # current_user = { name: "Bob" }
57
+ # `const user = $user_obj`[j current_user]
58
+ #
59
+ # # outputs:
60
+ # # const user = {"name":"Bob"}
61
+ #
62
+ # @param item [#to_json] Any object that responds to `#to_json`
63
+ # @return [String] JSON representation of the input
64
+ def j(item) = item.to_json
65
+
66
+ # Short for "string content"; returns escaped version of the string that can be inserted into a JavaScript
67
+ # string literal or template literal.
68
+ #
69
+ # Useful for inserting data into a string or for outputting a string but using quotes to make it clear to the
70
+ # reader what the intended output will be.
71
+ #
72
+ # It also escapes single quotes and backticks so that it can be inserted into single-quoted strings and string
73
+ # templates.
74
+ #
75
+ # @example Inserting into a string literal
76
+ # name = %q{Bob "the Machine" O'Brian}
77
+ # `const admin = "$name (Admin)"`[s name]
78
+ #
79
+ # # outputs:
80
+ # # const admin = "Bob \"the Machine\" O\'Brian (Admin)"
81
+ #
82
+ # @example Make your code communicate that a value will be a string
83
+ # name = %q{Bob "the Machine" O'Brian}
84
+ # `const admin = "$name"`[s name]
85
+ #
86
+ # # We could use the `#j` helper, too, but `#s` and quotes makes it clearer that the value will be a string
87
+ # `const admin = $name`[j name]
88
+ #
89
+ # @param item [#to_s] A string or any object that responds to `#to_s`
90
+ # @return [String] A JSON string without quotes
91
+ def s(item) = item.to_s.to_json[1...-1].gsub("'", "\\\\'").gsub("`", "\\\\`")
92
+ end
93
+ end
94
+ end
95
+ end