ast-merge 1.1.0 → 2.0.1

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +198 -7
  4. data/README.md +208 -39
  5. data/exe/ast-merge-recipe +366 -0
  6. data/lib/ast/merge/conflict_resolver_base.rb +8 -1
  7. data/lib/ast/merge/content_match_refiner.rb +278 -0
  8. data/lib/ast/merge/debug_logger.rb +2 -1
  9. data/lib/ast/merge/detector/base.rb +193 -0
  10. data/lib/ast/merge/detector/fenced_code_block.rb +227 -0
  11. data/lib/ast/merge/detector/mergeable.rb +369 -0
  12. data/lib/ast/merge/detector/toml_frontmatter.rb +82 -0
  13. data/lib/ast/merge/detector/yaml_frontmatter.rb +82 -0
  14. data/lib/ast/merge/merge_result_base.rb +4 -1
  15. data/lib/ast/merge/navigable_statement.rb +630 -0
  16. data/lib/ast/merge/partial_template_merger.rb +432 -0
  17. data/lib/ast/merge/recipe/config.rb +198 -0
  18. data/lib/ast/merge/recipe/preset.rb +171 -0
  19. data/lib/ast/merge/recipe/runner.rb +254 -0
  20. data/lib/ast/merge/recipe/script_loader.rb +181 -0
  21. data/lib/ast/merge/recipe.rb +26 -0
  22. data/lib/ast/merge/rspec/dependency_tags.rb +252 -0
  23. data/lib/ast/merge/rspec/shared_examples/reproducible_merge.rb +3 -2
  24. data/lib/ast/merge/rspec.rb +33 -2
  25. data/lib/ast/merge/smart_merger_base.rb +86 -3
  26. data/lib/ast/merge/version.rb +1 -1
  27. data/lib/ast/merge.rb +10 -6
  28. data/sig/ast/merge.rbs +389 -2
  29. data.tar.gz.sig +0 -0
  30. metadata +60 -16
  31. metadata.gz.sig +0 -0
  32. data/lib/ast/merge/fenced_code_block_detector.rb +0 -313
  33. data/lib/ast/merge/region.rb +0 -124
  34. data/lib/ast/merge/region_detector_base.rb +0 -114
  35. data/lib/ast/merge/region_mergeable.rb +0 -364
  36. data/lib/ast/merge/toml_frontmatter_detector.rb +0 -88
  37. data/lib/ast/merge/yaml_frontmatter_detector.rb +0 -88
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ast
4
+ module Merge
5
+ # Merges a partial template into a specific section of a destination document.
6
+ #
7
+ # Unlike the full SmartMerger which merges entire documents, PartialTemplateMerger:
8
+ # 1. Finds a specific section in the destination (using InjectionPoint)
9
+ # 2. Replaces/merges only that section with the template
10
+ # 3. Leaves the rest of the destination unchanged
11
+ #
12
+ # This is useful for updating a specific section (like a "Gem Family" section)
13
+ # across multiple files while preserving file-specific content.
14
+ #
15
+ # @example Basic usage
16
+ # merger = PartialTemplateMerger.new(
17
+ # template: template_content,
18
+ # destination: destination_content,
19
+ # anchor: { type: :heading, text: /Gem Family/ },
20
+ # parser: :markly
21
+ # )
22
+ # result = merger.merge
23
+ # puts result.content
24
+ #
25
+ # @example With boundary
26
+ # merger = PartialTemplateMerger.new(
27
+ # template: template_content,
28
+ # destination: destination_content,
29
+ # anchor: { type: :heading, text: /Installation/ },
30
+ # boundary: { type: :heading }, # Stop at next heading
31
+ # parser: :markly
32
+ # )
33
+ #
34
+ class PartialTemplateMerger
35
+ # Result of a partial template merge
36
+ class Result
37
+ # @return [String] The merged content
38
+ attr_reader :content
39
+
40
+ # @return [Boolean] Whether the destination had a matching section
41
+ attr_reader :has_section
42
+
43
+ # @return [Boolean] Whether the content changed
44
+ attr_reader :changed
45
+
46
+ # @return [Hash] Statistics about the merge
47
+ attr_reader :stats
48
+
49
+ # @return [InjectionPoint, nil] The injection point found (if any)
50
+ attr_reader :injection_point
51
+
52
+ # @return [String, nil] Message about the merge
53
+ attr_reader :message
54
+
55
+ def initialize(content:, has_section:, changed:, stats: {}, injection_point: nil, message: nil)
56
+ @content = content
57
+ @has_section = has_section
58
+ @changed = changed
59
+ @stats = stats
60
+ @injection_point = injection_point
61
+ @message = message
62
+ end
63
+
64
+ # @return [Boolean] Whether a section was found
65
+ def section_found?
66
+ has_section
67
+ end
68
+ end
69
+
70
+ # @return [String] The template content (the section to inject)
71
+ attr_reader :template
72
+
73
+ # @return [String] The destination content
74
+ attr_reader :destination
75
+
76
+ # @return [Hash] Anchor matcher configuration
77
+ attr_reader :anchor
78
+
79
+ # @return [Hash, nil] Boundary matcher configuration
80
+ attr_reader :boundary
81
+
82
+ # @return [Symbol] Parser to use (:markly, :commonmarker, etc.)
83
+ attr_reader :parser
84
+
85
+ # @return [Symbol, Hash] Merge preference (:template, :destination, or per-type hash)
86
+ attr_reader :preference
87
+
88
+ # @return [Boolean, Proc] Whether to add template-only nodes
89
+ attr_reader :add_missing
90
+
91
+ # @return [Symbol] What to do when section not found (:skip, :append, :prepend)
92
+ attr_reader :when_missing
93
+
94
+ # @return [Proc, nil] Custom signature generator for node matching
95
+ attr_reader :signature_generator
96
+
97
+ # @return [Hash, nil] Node typing configuration for per-type preferences
98
+ attr_reader :node_typing
99
+
100
+ # Initialize a PartialTemplateMerger.
101
+ #
102
+ # @param template [String] The template content (the section to merge in)
103
+ # @param destination [String] The destination content
104
+ # @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
105
+ # @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
106
+ # @param parser [Symbol] Parser to use (:markly, :commonmarker, :prism, :psych)
107
+ # @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
108
+ # @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
109
+ # @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
110
+ # @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
111
+ # @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
112
+ # @param node_typing [Hash, nil] Node typing configuration for per-type preferences
113
+ def initialize(
114
+ template:,
115
+ destination:,
116
+ anchor:,
117
+ boundary: nil,
118
+ parser: :markly,
119
+ preference: :template,
120
+ add_missing: true,
121
+ when_missing: :skip,
122
+ replace_mode: false,
123
+ signature_generator: nil,
124
+ node_typing: nil
125
+ )
126
+ @template = template
127
+ @destination = destination
128
+ @anchor = normalize_matcher(anchor)
129
+ @boundary = boundary ? normalize_matcher(boundary) : nil
130
+ @parser = parser
131
+ @preference = preference
132
+ @add_missing = add_missing
133
+ @when_missing = when_missing
134
+ @replace_mode = replace_mode
135
+ @signature_generator = signature_generator
136
+ @node_typing = node_typing
137
+ end
138
+
139
+ # Perform the partial template merge.
140
+ #
141
+ # @return [Result] The merge result
142
+ def merge
143
+ # Parse destination and find injection point
144
+ d_analysis = create_analysis(destination)
145
+ d_statements = NavigableStatement.build_list(d_analysis.statements)
146
+
147
+ finder = InjectionPointFinder.new(d_statements)
148
+ injection_point = finder.find(
149
+ type: anchor[:type],
150
+ text: anchor[:text],
151
+ position: :replace,
152
+ boundary_type: boundary&.dig(:type),
153
+ boundary_text: boundary&.dig(:text),
154
+ )
155
+
156
+ if injection_point.nil?
157
+ return handle_missing_section(d_analysis)
158
+ end
159
+
160
+ # Found the section - now merge
161
+ perform_section_merge(d_analysis, d_statements, injection_point)
162
+ end
163
+
164
+ private
165
+
166
+ def normalize_matcher(matcher)
167
+ return {} if matcher.nil?
168
+
169
+ result = {}
170
+ result[:type] = matcher[:type]&.to_sym
171
+ result[:text] = normalize_text_pattern(matcher[:text])
172
+ result[:level] = matcher[:level] if matcher[:level]
173
+ result[:level_lte] = matcher[:level_lte] if matcher[:level_lte]
174
+ result[:level_gte] = matcher[:level_gte] if matcher[:level_gte]
175
+ result.compact
176
+ end
177
+
178
+ def normalize_text_pattern(text)
179
+ return if text.nil?
180
+ return text if text.is_a?(Regexp)
181
+
182
+ # Handle /regex/ syntax in strings
183
+ if text.is_a?(String) && text.start_with?("/") && text.end_with?("/")
184
+ Regexp.new(text[1..-2])
185
+ else
186
+ text
187
+ end
188
+ end
189
+
190
+ def handle_missing_section(_d_analysis)
191
+ case when_missing
192
+ when :skip
193
+ Result.new(
194
+ content: destination,
195
+ has_section: false,
196
+ changed: false,
197
+ message: "Section not found, skipping",
198
+ )
199
+ when :append
200
+ # Append template at end of document
201
+ new_content = destination.chomp + "\n\n" + template
202
+ Result.new(
203
+ content: new_content,
204
+ has_section: false,
205
+ changed: true,
206
+ message: "Section not found, appended template",
207
+ )
208
+ when :prepend
209
+ # Prepend template at start (after any frontmatter)
210
+ new_content = template + "\n\n" + destination
211
+ Result.new(
212
+ content: new_content,
213
+ has_section: false,
214
+ changed: true,
215
+ message: "Section not found, prepended template",
216
+ )
217
+ else
218
+ Result.new(
219
+ content: destination,
220
+ has_section: false,
221
+ changed: false,
222
+ message: "Section not found, no action taken",
223
+ )
224
+ end
225
+ end
226
+
227
+ def perform_section_merge(_d_analysis, d_statements, injection_point)
228
+ # Determine section boundaries in destination
229
+ section_start_idx = injection_point.anchor.index
230
+ section_end_idx = find_section_end(d_statements, injection_point)
231
+
232
+ # Extract the three parts: before, section, after
233
+ before_statements = d_statements[0...section_start_idx]
234
+ section_statements = d_statements[section_start_idx..section_end_idx]
235
+ after_statements = d_statements[(section_end_idx + 1)..]
236
+
237
+ # Determine the merged section content
238
+ section_content = statements_to_content(section_statements)
239
+ merged_section, stats = merge_section_content(section_content)
240
+
241
+ # Reconstruct the document
242
+ before_content = statements_to_content(before_statements)
243
+ after_content = statements_to_content(after_statements)
244
+
245
+ new_content = build_merged_content(before_content, merged_section, after_content)
246
+
247
+ changed = new_content != destination
248
+
249
+ Result.new(
250
+ content: new_content,
251
+ has_section: true,
252
+ changed: changed,
253
+ stats: stats,
254
+ injection_point: injection_point,
255
+ message: changed ? "Section merged successfully" : "Section unchanged",
256
+ )
257
+ end
258
+
259
+ def merge_section_content(section_content)
260
+ # Use SmartMerger for intelligent merging of the section
261
+ # The behavior depends on preference setting:
262
+ # - :template with replace_mode: true -> full replacement
263
+ # - :template with replace_mode: false -> merge with template winning conflicts
264
+ # - :destination -> merge with destination winning conflicts
265
+
266
+ if replace_mode?
267
+ # Full replacement: just use template content directly
268
+ [template, {mode: :replace}]
269
+ else
270
+ # Intelligent merge: use SmartMerger
271
+ merger = create_smart_merger(template, section_content)
272
+ result = merger.merge_result
273
+ [result.content, result.stats.merge(mode: :merge)]
274
+ end
275
+ end
276
+
277
+ # Check if we're in replace mode (vs merge mode)
278
+ # Replace mode means template completely replaces the section
279
+ def replace_mode?
280
+ @replace_mode == true
281
+ end
282
+
283
+ def find_section_end(statements, injection_point)
284
+ # If boundary was specified and found, use it (exclusive - section ends before boundary)
285
+ if injection_point.boundary
286
+ return injection_point.boundary.index - 1
287
+ end
288
+
289
+ # Otherwise, find the next node of same type (for headings, same or higher level)
290
+ anchor = injection_point.anchor
291
+ anchor_type = anchor.type
292
+
293
+ # For headings, find next heading of same or higher level
294
+ if heading_type?(anchor_type)
295
+ anchor_level = get_heading_level(anchor)
296
+
297
+ ((anchor.index + 1)...statements.length).each do |idx|
298
+ stmt = statements[idx]
299
+ if heading_type?(stmt.type)
300
+ stmt_level = get_heading_level(stmt)
301
+ if stmt_level && anchor_level && stmt_level <= anchor_level
302
+ # Found next heading of same or higher level - section ends before it
303
+ return idx - 1
304
+ end
305
+ end
306
+ end
307
+ else
308
+ # For non-headings, find next node of same type
309
+ ((anchor.index + 1)...statements.length).each do |idx|
310
+ stmt = statements[idx]
311
+ if stmt.type == anchor_type
312
+ return idx - 1
313
+ end
314
+ end
315
+ end
316
+
317
+ # Section extends to end of document
318
+ statements.length - 1
319
+ end
320
+
321
+ def heading_type?(type)
322
+ type.to_s == "heading" || type == :heading || type == :header
323
+ end
324
+
325
+ def get_heading_level(stmt)
326
+ inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
327
+
328
+ if inner.respond_to?(:header_level)
329
+ inner.header_level
330
+ elsif inner.respond_to?(:level)
331
+ inner.level
332
+ end
333
+ end
334
+
335
+ def statements_to_content(statements)
336
+ return "" if statements.nil? || statements.empty?
337
+
338
+ statements.map do |stmt|
339
+ node = stmt.respond_to?(:node) ? stmt.node : stmt
340
+ node_to_text(node)
341
+ end.join
342
+ end
343
+
344
+ def node_to_text(node)
345
+ # Unwrap if needed
346
+ inner = node
347
+ while inner.respond_to?(:inner_node) && inner.inner_node != inner
348
+ inner = inner.inner_node
349
+ end
350
+
351
+ if inner.respond_to?(:to_commonmark)
352
+ inner.to_commonmark.to_s
353
+ elsif inner.respond_to?(:to_s)
354
+ inner.to_s
355
+ else
356
+ ""
357
+ end
358
+ end
359
+
360
+ def build_merged_content(before, section, after)
361
+ parts = []
362
+
363
+ # Before content
364
+ unless before.nil? || before.strip.empty?
365
+ parts << before.chomp
366
+ end
367
+
368
+ # Merged section
369
+ unless section.nil? || section.strip.empty?
370
+ parts << section.chomp
371
+ end
372
+
373
+ # After content
374
+ unless after.nil? || after.strip.empty?
375
+ parts << after.chomp
376
+ end
377
+
378
+ result = parts.join("\n\n")
379
+ result += "\n" unless result.end_with?("\n")
380
+ result
381
+ end
382
+
383
+ def create_analysis(content)
384
+ case parser
385
+ when :markly
386
+ require "markly/merge" unless defined?(Markly::Merge)
387
+ Markly::Merge::FileAnalysis.new(content)
388
+ when :commonmarker
389
+ require "commonmarker/merge" unless defined?(Commonmarker::Merge)
390
+ Commonmarker::Merge::FileAnalysis.new(content)
391
+ when :prism
392
+ require "prism/merge" unless defined?(Prism::Merge)
393
+ Prism::Merge::FileAnalysis.new(content)
394
+ when :psych
395
+ require "psych/merge" unless defined?(Psych::Merge)
396
+ Psych::Merge::FileAnalysis.new(content)
397
+ else
398
+ raise ArgumentError, "Unknown parser: #{parser}"
399
+ end
400
+ end
401
+
402
+ def create_smart_merger(template_content, destination_content)
403
+ merger_class = case parser
404
+ when :markly
405
+ require "markly/merge" unless defined?(Markly::Merge)
406
+ Markly::Merge::SmartMerger
407
+ when :commonmarker
408
+ require "commonmarker/merge" unless defined?(Commonmarker::Merge)
409
+ Commonmarker::Merge::SmartMerger
410
+ when :prism
411
+ require "prism/merge" unless defined?(Prism::Merge)
412
+ Prism::Merge::SmartMerger
413
+ when :psych
414
+ require "psych/merge" unless defined?(Psych::Merge)
415
+ Psych::Merge::SmartMerger
416
+ else
417
+ raise ArgumentError, "Unknown parser: #{parser}"
418
+ end
419
+
420
+ # Build options hash, only including non-nil values
421
+ options = {
422
+ preference: preference,
423
+ add_template_only_nodes: add_missing,
424
+ }
425
+ options[:signature_generator] = signature_generator if signature_generator
426
+ options[:node_typing] = node_typing if node_typing
427
+
428
+ merger_class.new(template_content, destination_content, **options)
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "preset"
5
+
6
+ module Ast
7
+ module Merge
8
+ module Recipe
9
+ # Loads and represents a merge recipe from YAML configuration.
10
+ #
11
+ # A recipe extends Preset with:
12
+ # - Template file specification
13
+ # - Target file patterns
14
+ # - Injection point configuration
15
+ # - when_missing behavior
16
+ #
17
+ # @example Loading a recipe
18
+ # recipe = Config.load(".merge-recipes/gem_family_section.yml")
19
+ # recipe.name # => "gem_family_section"
20
+ # recipe.template_path # => "GEM_FAMILY_SECTION.md"
21
+ # recipe.targets # => ["README.md", "vendor/*/README.md"]
22
+ #
23
+ # @example Recipe YAML format
24
+ # name: gem_family_section
25
+ # description: Update gem family section in README files
26
+ #
27
+ # template: GEM_FAMILY_SECTION.md
28
+ #
29
+ # targets:
30
+ # - "README.md"
31
+ # - "vendor/*/README.md"
32
+ #
33
+ # injection:
34
+ # anchor:
35
+ # type: heading
36
+ # text: /Gem Family/
37
+ # position: replace
38
+ # boundary:
39
+ # type: heading
40
+ # same_or_shallower: true
41
+ #
42
+ # merge:
43
+ # preference: template
44
+ # add_missing: true
45
+ #
46
+ # when_missing: skip
47
+ #
48
+ # @see Preset For base configuration without template/targets
49
+ # @see Runner For executing recipes
50
+ # @see ScriptLoader For loading Ruby scripts from recipe folders
51
+ class Config < Preset
52
+ # @return [String] Path to template file (relative to recipe or absolute)
53
+ attr_reader :template_path
54
+
55
+ # @return [Array<String>] Glob patterns for target files
56
+ attr_reader :targets
57
+
58
+ # @return [Hash] Injection point configuration
59
+ attr_reader :injection
60
+
61
+ # @return [Symbol] Behavior when injection anchor not found (:skip, :add, :error)
62
+ attr_reader :when_missing
63
+
64
+ # Alias for compatibility - recipe_path points to the same file as preset_path
65
+ def recipe_path
66
+ preset_path
67
+ end
68
+
69
+ class << self
70
+ # Load a recipe from a YAML file.
71
+ #
72
+ # @param path [String] Path to the recipe YAML file
73
+ # @return [Config] Loaded recipe
74
+ # @raise [ArgumentError] If file doesn't exist or is invalid
75
+ def load(path)
76
+ raise ArgumentError, "Recipe file not found: #{path}" unless File.exist?(path)
77
+
78
+ yaml = YAML.safe_load_file(path, permitted_classes: [Regexp, Symbol])
79
+ new(yaml, preset_path: path)
80
+ end
81
+ end
82
+
83
+ # Create a recipe from a hash (parsed YAML or programmatic).
84
+ #
85
+ # @param config [Hash] Recipe configuration
86
+ # @param preset_path [String, nil] Path to recipe file (for relative path resolution)
87
+ # @param recipe_path [String, nil] Alias for preset_path (backward compatibility)
88
+ def initialize(config, preset_path: nil, recipe_path: nil)
89
+ # Support both preset_path and recipe_path for backward compatibility
90
+ effective_path = preset_path || recipe_path
91
+ super(config, preset_path: effective_path)
92
+
93
+ @template_path = config["template"] || raise(ArgumentError, "Recipe must have 'template' key")
94
+ @targets = Array(config["targets"] || ["*.md"])
95
+ @injection = parse_injection(config["injection"] || {})
96
+ @when_missing = (config["when_missing"] || "skip").to_sym
97
+ end
98
+
99
+ # Get the absolute path to the template file.
100
+ #
101
+ # @param base_dir [String] Base directory for relative paths
102
+ # @return [String] Absolute path to template
103
+ def template_absolute_path(base_dir: nil)
104
+ return @template_path if File.absolute_path?(@template_path)
105
+
106
+ base = base_dir || (preset_path ? File.dirname(preset_path) : Dir.pwd)
107
+ File.expand_path(@template_path, base)
108
+ end
109
+
110
+ # Expand target globs to actual file paths.
111
+ #
112
+ # @param base_dir [String] Base directory for glob expansion
113
+ # @return [Array<String>] Absolute paths to target files
114
+ def expand_targets(base_dir: nil)
115
+ base = base_dir || (preset_path ? File.dirname(preset_path) : Dir.pwd)
116
+
117
+ targets.flat_map do |pattern|
118
+ if File.absolute_path?(pattern)
119
+ Dir.glob(pattern)
120
+ else
121
+ # Expand and normalize to remove .. segments
122
+ expanded_pattern = File.expand_path(pattern, base)
123
+ Dir.glob(expanded_pattern)
124
+ end
125
+ end.uniq.sort
126
+ end
127
+
128
+ # Build an InjectionPointFinder query from the injection config.
129
+ #
130
+ # @return [Hash] Arguments for InjectionPointFinder#find
131
+ def finder_query
132
+ anchor = injection[:anchor] || {}
133
+ boundary = injection[:boundary] || {}
134
+
135
+ query = {
136
+ type: anchor[:type],
137
+ text: anchor[:text],
138
+ position: injection[:position] || :replace,
139
+ boundary_type: boundary[:type],
140
+ boundary_text: boundary[:text],
141
+ }
142
+
143
+ # Support tree-depth based boundary detection
144
+ # same_or_shallower: true means "end at next sibling (same tree level or above)"
145
+ if boundary[:same_or_shallower]
146
+ query[:boundary_same_or_shallower] = true
147
+ end
148
+
149
+ query.compact
150
+ end
151
+
152
+ # Whether to use replace mode (template replaces section entirely).
153
+ #
154
+ # @return [Boolean]
155
+ def replace_mode?
156
+ merge_config[:replace_mode] == true
157
+ end
158
+
159
+ private
160
+
161
+ def parse_injection(config)
162
+ return {} if config.empty?
163
+
164
+ {
165
+ anchor: parse_matcher(config["anchor"] || {}),
166
+ position: (config["position"] || "replace").to_sym,
167
+ boundary: parse_matcher(config["boundary"] || {}),
168
+ }
169
+ end
170
+
171
+ def parse_matcher(config)
172
+ return if config.empty?
173
+
174
+ {
175
+ type: config["type"]&.to_sym,
176
+ text: parse_text_pattern(config["text"]),
177
+ level: config["level"],
178
+ level_lte: config["level_lte"],
179
+ level_gte: config["level_gte"],
180
+ same_or_shallower: config["same_or_shallower"] == true,
181
+ }.compact
182
+ end
183
+
184
+ def parse_text_pattern(text)
185
+ return if text.nil?
186
+ return text if text.is_a?(Regexp)
187
+
188
+ # Handle /regex/ syntax in YAML strings
189
+ if text.is_a?(String) && text.start_with?("/") && text.end_with?("/")
190
+ Regexp.new(text[1..-2])
191
+ else
192
+ text
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end