cecil 0.1.0

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