ruby-grafana-reporter 0.1.6

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +248 -0
  4. data/lib/VERSION.rb +3 -0
  5. data/lib/grafana/abstract_panel_query.rb +20 -0
  6. data/lib/grafana/abstract_query.rb +127 -0
  7. data/lib/grafana/abstract_sql_query.rb +42 -0
  8. data/lib/grafana/dashboard.rb +66 -0
  9. data/lib/grafana/errors.rb +61 -0
  10. data/lib/grafana/grafana.rb +131 -0
  11. data/lib/grafana/panel.rb +39 -0
  12. data/lib/grafana/panel_image_query.rb +49 -0
  13. data/lib/grafana/variable.rb +259 -0
  14. data/lib/grafana_reporter/abstract_report.rb +109 -0
  15. data/lib/grafana_reporter/application/application.rb +229 -0
  16. data/lib/grafana_reporter/application/errors.rb +30 -0
  17. data/lib/grafana_reporter/asciidoctor/alerts_table_query.rb +99 -0
  18. data/lib/grafana_reporter/asciidoctor/annotations_table_query.rb +96 -0
  19. data/lib/grafana_reporter/asciidoctor/errors.rb +37 -0
  20. data/lib/grafana_reporter/asciidoctor/extensions/alerts_table_include_processor.rb +86 -0
  21. data/lib/grafana_reporter/asciidoctor/extensions/annotations_table_include_processor.rb +86 -0
  22. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_block_macro.rb +67 -0
  23. data/lib/grafana_reporter/asciidoctor/extensions/panel_image_inline_macro.rb +65 -0
  24. data/lib/grafana_reporter/asciidoctor/extensions/panel_property_inline_macro.rb +58 -0
  25. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_table_include_processor.rb +75 -0
  26. data/lib/grafana_reporter/asciidoctor/extensions/panel_query_value_inline_macro.rb +70 -0
  27. data/lib/grafana_reporter/asciidoctor/extensions/processor_mixin.rb +18 -0
  28. data/lib/grafana_reporter/asciidoctor/extensions/show_environment_include_processor.rb +41 -0
  29. data/lib/grafana_reporter/asciidoctor/extensions/show_help_include_processor.rb +202 -0
  30. data/lib/grafana_reporter/asciidoctor/extensions/sql_table_include_processor.rb +67 -0
  31. data/lib/grafana_reporter/asciidoctor/extensions/sql_value_inline_macro.rb +65 -0
  32. data/lib/grafana_reporter/asciidoctor/extensions/value_as_variable_include_processor.rb +57 -0
  33. data/lib/grafana_reporter/asciidoctor/panel_first_value_query.rb +32 -0
  34. data/lib/grafana_reporter/asciidoctor/panel_image_query.rb +23 -0
  35. data/lib/grafana_reporter/asciidoctor/panel_property_query.rb +43 -0
  36. data/lib/grafana_reporter/asciidoctor/panel_table_query.rb +36 -0
  37. data/lib/grafana_reporter/asciidoctor/query_mixin.rb +309 -0
  38. data/lib/grafana_reporter/asciidoctor/report.rb +159 -0
  39. data/lib/grafana_reporter/asciidoctor/sql_first_value_query.rb +34 -0
  40. data/lib/grafana_reporter/asciidoctor/sql_table_query.rb +32 -0
  41. data/lib/grafana_reporter/configuration.rb +326 -0
  42. data/lib/grafana_reporter/errors.rb +38 -0
  43. data/lib/grafana_reporter/logger/two_way_logger.rb +52 -0
  44. data/lib/ruby-grafana-reporter.rb +27 -0
  45. metadata +88 -0
@@ -0,0 +1,96 @@
1
+ module GrafanaReporter
2
+ module Asciidoctor
3
+ # This class is used to query annotations from grafana.
4
+ class AnnotationsTableQuery < Grafana::AbstractQuery
5
+ include QueryMixin
6
+
7
+ # @option opts [Grafana::Dashboard] :dashboard dashboard, if annotations shall be filtered for a dashboard
8
+ # @option opts [Grafana::Panel] :panel panel, if annotations shall be filtered for a panel
9
+ def initialize(opts = {})
10
+ super()
11
+
12
+ @dashboard = opts[:dashboard]
13
+ @panel = opts[:panel]
14
+ @dashboard = @panel.dashboard if @panel
15
+
16
+ extract_dashboard_variables(@dashboard) if @dashboard
17
+ end
18
+
19
+ # @return [String] URL for querying annotations
20
+ def url
21
+ '/api/annotations' + url_parameters
22
+ end
23
+
24
+ # @return [Hash] empty hash object
25
+ def request
26
+ {}
27
+ end
28
+
29
+ # Check if mandatory {Grafana::Variable} +columns+ is specified in variables.
30
+ #
31
+ # The value of the +columns+ variable has to be a comma separated list of column titles, which
32
+ # need to be included in the following list:
33
+ # - limit
34
+ # - alertId
35
+ # - userId
36
+ # - type
37
+ # - tags
38
+ # - dashboardId
39
+ # - panelId
40
+ # @return [void]
41
+ def pre_process(_grafana)
42
+ raise MissingMandatoryAttributeError, 'columns' unless @variables['columns']
43
+
44
+ @from = translate_date(@from, @variables['grafana-report-timestamp'], false)
45
+ @to = translate_date(@to, @variables['grafana-report-timestamp'], true)
46
+ end
47
+
48
+ # Filters the query result for the given columns and sets the result
49
+ # in the preformatted SQL result style.
50
+ #
51
+ # Additionally it applies {QueryMixin#format_columns}, {QueryMixin#replace_values} and
52
+ # {QueryMixin#filter_columns}.
53
+ # @return [void]
54
+ def post_process
55
+ # extract data from returned json
56
+ result = JSON.parse(@result.body)
57
+ content = []
58
+ begin
59
+ result.each { |item| content << item.fetch_values(*@variables['columns'].raw_value.split(',')) }
60
+ rescue KeyError => e
61
+ raise MalformedAttributeContentError.new(e.message, 'columns', @variables['columns'])
62
+ end
63
+
64
+ result = {}
65
+ result[:header] = [@variables['columns'].raw_value.split(',')]
66
+ result[:content] = content
67
+
68
+ result = format_columns(result, @variables['format'])
69
+ result = replace_values(result, @variables.select { |k, _v| k =~ /^replace_values_\d+/ })
70
+ result = filter_columns(result, @variables['filter_columns'])
71
+ if @variables['filter_column']
72
+ @report.logger.warn("DEPRECATED: Call of no longer supported function 'filter_column' has been found. Rename to 'filter_columns'")
73
+ result = filter_columns(result, @variables['filter_column'])
74
+ end
75
+
76
+ @result = result[:content].map { |row| '| ' + row.map { |item| item.to_s.gsub('|', '\\|') }.join(' | ') }
77
+ end
78
+
79
+ private
80
+
81
+ def url_parameters
82
+ url_vars = {}
83
+ url_vars['dashboardId'] = ::Grafana::Variable.new(@dashboard.id) if @dashboard
84
+ url_vars['panelId'] = ::Grafana::Variable.new(@panel.id) if @panel
85
+
86
+ url_vars.merge!(variables.select { |k, _v| k =~ /^(?:limit|alertId|dashboardId|panelId|userId|type|tags)/ })
87
+ url_vars['from'] = ::Grafana::Variable.new(@from) if @from
88
+ url_vars['to'] = ::Grafana::Variable.new(@to) if @to
89
+ url_params = URI.encode_www_form(url_vars.map { |k, v| [k, v.raw_value.to_s] })
90
+ return '' if url_params.empty?
91
+
92
+ '?' + url_params
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,37 @@
1
+ module GrafanaReporter
2
+ # This module contains all classes, which are necessary to use the grafana
3
+ # reporter to be used in conjunction with asciidoctor.
4
+ module Asciidoctor
5
+ # Thrown, if the value configuration in {QueryMixin#replace_values} is
6
+ # invalid.
7
+ class MalformedReplaceValuesStatementError < GrafanaReporterError
8
+ def initialize(statement)
9
+ super("The specified replace_values statement '#{statement}' is invalid. Make sure it contains exactly one not escaped ':' symbol.")
10
+ end
11
+ end
12
+
13
+ # Thrown, if a configured parameter is malformed.
14
+ class MalformedAttributeContentError < GrafanaReporterError
15
+ def initialize(message, attribute, content)
16
+ super("The content '#{content}' in attribute '#{attribute}' is malformed: #{message}")
17
+ end
18
+ end
19
+
20
+ # Thrown, if a configured time range is not supported by the reporter.
21
+ #
22
+ # If this happens, most likely the reporter has to implement the new
23
+ # time range definition.
24
+ class TimeRangeUnknownError < GrafanaReporterError
25
+ def initialize(time_range)
26
+ super("The specified time range '#{time_range}' is unknown.")
27
+ end
28
+ end
29
+
30
+ # Thrown, if a mandatory attribute is not set.
31
+ class MissingMandatoryAttributeError < GrafanaReporterError
32
+ def initialize(attribute)
33
+ super("Missing mandatory attribute '#{attribute}'.")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,86 @@
1
+ require_relative 'processor_mixin'
2
+
3
+ module GrafanaReporter
4
+ module Asciidoctor
5
+ module Extensions
6
+ # Implements the hook
7
+ # include::grafana_alerts[<options>]
8
+ #
9
+ # Returns the results of alerts query as a asciidoctor table.
10
+ #
11
+ # == Used document parameters
12
+ # +grafana_default_instance+ - name of grafana instance, 'default' if not specified
13
+ #
14
+ # +grafana_default_dashboard+ - uid of grafana default dashboard to use
15
+ #
16
+ # +from+ - 'from' time for the sql query
17
+ #
18
+ # +to+ - 'to' time for the sql query
19
+ #
20
+ # == Supported options
21
+ # +columns+ - see {AlertsTableQuery#pre_process} (*mandatory*)
22
+ #
23
+ # +instance+ - name of grafana instance, 'default' if not specified
24
+ #
25
+ # +dashboard+ - uid of grafana dashboard to query for, empty string if no filter is wanted
26
+ #
27
+ # +panel+ - id of the panel to query for
28
+ #
29
+ # +from+ - 'from' time for the sql query
30
+ #
31
+ # +to+ - 'to' time for the sql query
32
+ #
33
+ # +format+ - see {QueryMixin#format_columns}
34
+ #
35
+ # +replace_values+ - see {QueryMixin#replace_values}
36
+ #
37
+ # +filter_columns+ - see {QueryMixin#filter_columns}
38
+ class AlertsTableIncludeProcessor < ::Asciidoctor::Extensions::IncludeProcessor
39
+ include ProcessorMixin
40
+
41
+ # :nodoc:
42
+ def handles?(target)
43
+ target.start_with? 'grafana_alerts'
44
+ end
45
+
46
+ # :nodoc:
47
+ def process(doc, reader, _target, attrs)
48
+ return if @report.cancel
49
+
50
+ @report.next_step
51
+ instance = attrs['instance'] || doc.attr('grafana_default_instance') || 'default'
52
+ dashboard_id = attrs['dashboard'] || doc.attr('grafana_default_dashboard')
53
+ panel_id = attrs['panel']
54
+ @report.logger.debug("Processing AlertsTableIncludeProcessor (instance: #{instance}, dashboard: #{dashboard_id}, panel: #{panel_id})")
55
+
56
+ query = if dashboard_id.to_s.empty?
57
+ # no dashboard shall be used, so also the panel will be omitted
58
+ AlertsTableQuery.new
59
+ elsif panel_id.to_s.empty?
60
+ # a dashboard is given, but no panel, so set filter for dashboard only
61
+ AlertsTableQuery.new(dashboard: @report.grafana(instance).dashboard(dashboard_id))
62
+ else
63
+ # dashboard and panel is given, so set filter for panel
64
+ AlertsTableQuery.new(panel: @report.grafana(instance).dashboard(dashboard_id).panel(panel_id))
65
+ end
66
+
67
+ query.merge_hash_variables(doc.attributes, attrs)
68
+ query.merge_variables(attrs.select { |k, _v| k =~ /(?:columns|limit|folderId|dashboardId|panelId|dahboardTag|dashboardQuery|state|query)/ }.transform_values { |item| ::Grafana::Variable.new(item) })
69
+ @report.logger.debug("from: #{query.from}, to: #{query.to}")
70
+
71
+ begin
72
+ reader.unshift_lines query.execute(@report.grafana(instance))
73
+ rescue GrafanaReporterError => e
74
+ @report.logger.error(e.message)
75
+ reader.unshift_line '|' + e.message
76
+ rescue StandardError => e
77
+ @report.logger.fatal(e.message)
78
+ reader.unshift_line '|' + e.message
79
+ end
80
+
81
+ reader
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,86 @@
1
+ require_relative 'processor_mixin'
2
+
3
+ module GrafanaReporter
4
+ module Asciidoctor
5
+ module Extensions
6
+ # Implements the hook
7
+ # include::grafana_annotations[<options>]
8
+ #
9
+ # Returns the results of alerts query as a asciidoctor table.
10
+ #
11
+ # == Used document parameters
12
+ # +grafana_default_instance+ - name of grafana instance, 'default' if not specified
13
+ #
14
+ # +grafana_default_dashboard+ - uid of grafana default dashboard to use
15
+ #
16
+ # +from+ - 'from' time for the sql query
17
+ #
18
+ # +to+ - 'to' time for the sql query
19
+ #
20
+ # == Supported options
21
+ # +columns+ - see {AnnotationsTableQuery#pre_process} (*mandatory*)
22
+ #
23
+ # +instance+ - name of grafana instance, 'default' if not specified
24
+ #
25
+ # +dashboard+ - uid of grafana dashboard to query for, empty string if no filter is wanted
26
+ #
27
+ # +panel+ - id of the panel to query for
28
+ #
29
+ # +from+ - 'from' time for the sql query
30
+ #
31
+ # +to+ - 'to' time for the sql query
32
+ #
33
+ # +format+ - see {QueryMixin#format_columns}
34
+ #
35
+ # +replace_values+ - see {QueryMixin#replace_values}
36
+ #
37
+ # +filter_columns+ - see {QueryMixin#filter_columns}
38
+ class AnnotationsTableIncludeProcessor < ::Asciidoctor::Extensions::IncludeProcessor
39
+ include ProcessorMixin
40
+
41
+ # :nodoc:
42
+ def handles?(target)
43
+ target.start_with? 'grafana_annotations'
44
+ end
45
+
46
+ # :nodoc:
47
+ def process(doc, reader, _target, attrs)
48
+ return if @report.cancel
49
+
50
+ @report.next_step
51
+ instance = attrs['instance'] || doc.attr('grafana_default_instance') || 'default'
52
+ dashboard_id = attrs['dashboard'] || doc.attr('grafana_default_dashboard')
53
+ panel_id = attrs['panel']
54
+ @report.logger.debug("Processing AnnotationsTableIncludeProcessor (instance: #{instance})")
55
+
56
+ query = if dashboard_id.to_s.empty?
57
+ # no dashboard shall be used, so also the panel will be omitted
58
+ AnnotationsTableQuery.new
59
+ elsif panel_id.to_s.empty?
60
+ # a dashboard is given, but no panel, so set filter for dashboard only
61
+ AnnotationsTableQuery.new(dashboard: @report.grafana(instance).dashboard(dashboard_id))
62
+ else
63
+ # dashboard and panel is given, so set filter for panel
64
+ AnnotationsTableQuery.new(panel: @report.grafana(instance).dashboard(dashboard_id).panel(panel_id))
65
+ end
66
+
67
+ query.merge_hash_variables(doc.attributes, attrs)
68
+ query.merge_variables(attrs.select { |k, _v| k =~ /(?:columns|limit|alertId|dashboardId|panelId|userId|type|tags)/ }.transform_values { |item| ::Grafana::Variable.new(item) })
69
+ @report.logger.debug("from: #{query.from}, to: #{query.to}")
70
+
71
+ begin
72
+ reader.unshift_lines query.execute(@report.grafana(instance))
73
+ rescue GrafanaReporterError => e
74
+ @report.logger.error(e.message)
75
+ reader.unshift_line '|' + e.message
76
+ rescue StandardError => e
77
+ @report.logger.fatal(e.message)
78
+ reader.unshift_line '|' + e.message
79
+ end
80
+
81
+ reader
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,67 @@
1
+ require_relative 'processor_mixin'
2
+
3
+ module GrafanaReporter
4
+ module Asciidoctor
5
+ module Extensions
6
+ # Implements the hook
7
+ # grafana_panel_image::<panel_id>[<options>]
8
+ #
9
+ # Stores the queried panel as a temporary image file and returns an asciidoctor link
10
+ # to be included in the report.
11
+ #
12
+ # == Used document parameters
13
+ # +grafana_default_instance+ - name of grafana instance, 'default' if not specified
14
+ #
15
+ # +grafana_default_dashboard+ - uid of grafana default dashboard to use
16
+ #
17
+ # +from+ - 'from' time for the sql query
18
+ #
19
+ # +to+ - 'to' time for the sql query
20
+ #
21
+ # == Supported options
22
+ # +field+ - property to query for, e.g. +description+ or +title+ (*mandatory*)
23
+ #
24
+ # +instance+ - name of grafana instance, 'default' if not specified
25
+ #
26
+ # +dashboard+ - uid of grafana dashboard to use
27
+ #
28
+ # +from+ - 'from' time for the sql query
29
+ #
30
+ # +to+ - 'to' time for the sql query
31
+ class PanelImageBlockMacro < ::Asciidoctor::Extensions::BlockMacroProcessor
32
+ include ProcessorMixin
33
+ use_dsl
34
+
35
+ named :grafana_panel_image
36
+
37
+ # :nodoc:
38
+ def process(parent, target, attrs)
39
+ return if @report.cancel
40
+
41
+ @report.next_step
42
+ instance = attrs['instance'] || parent.document.attr('grafana_default_instance') || 'default'
43
+ dashboard = attrs['dashboard'] || parent.document.attr('grafana_default_dashboard')
44
+ @report.logger.debug("Processing PanelImageBlockMacro (instance: #{instance}, dashboard: #{dashboard}, panel: #{target})")
45
+ query = PanelImageQuery.new(@report.grafana(instance).dashboard(dashboard).panel(target))
46
+ query.merge_hash_variables(parent.document.attributes, attrs)
47
+ @report.logger.debug("from: #{query.from}, to: #{query.to}")
48
+
49
+ begin
50
+ image = query.execute(@report.grafana(instance))
51
+ image_path = @report.save_image_file(image)
52
+ rescue GrafanaReporterError => e
53
+ @report.logger.error(e.message)
54
+ return create_paragraph(parent, e.message, attrs)
55
+ rescue StandardError => e
56
+ @report.logger.fatal(e.message)
57
+ return create_paragraph(parent, e.message, attrs)
58
+ end
59
+
60
+ attrs['target'] = image_path
61
+ block = create_image_block(parent, attrs)
62
+ block
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,65 @@
1
+ require_relative 'processor_mixin'
2
+
3
+ module GrafanaReporter
4
+ module Asciidoctor
5
+ module Extensions
6
+ # Implements the hook
7
+ # grafana_panel_image:<panel_id>[<options>]
8
+ #
9
+ # Stores the queried panel as a temporary image file and returns an asciidoctor link
10
+ # to be included in the report.
11
+ #
12
+ # == Used document parameters
13
+ # +grafana_default_instance+ - name of grafana instance, 'default' if not specified
14
+ #
15
+ # +grafana_default_dashboard+ - uid of grafana default dashboard to use
16
+ #
17
+ # +from+ - 'from' time for the sql query
18
+ #
19
+ # +to+ - 'to' time for the sql query
20
+ #
21
+ # == Supported options
22
+ # +field+ - property to query for, e.g. +description+ or +title+ (*mandatory*)
23
+ #
24
+ # +instance+ - name of grafana instance, 'default' if not specified
25
+ #
26
+ # +dashboard+ - uid of grafana dashboard to use
27
+ #
28
+ # +from+ - 'from' time for the sql query
29
+ #
30
+ # +to+ - 'to' time for the sql query
31
+ class PanelImageInlineMacro < ::Asciidoctor::Extensions::InlineMacroProcessor
32
+ include ProcessorMixin
33
+ use_dsl
34
+
35
+ named :grafana_panel_image
36
+
37
+ # :nodoc:
38
+ def process(parent, target, attrs)
39
+ return if @report.cancel
40
+
41
+ @report.next_step
42
+ instance = attrs['instance'] || parent.document.attr('grafana_default_instance') || 'default'
43
+ dashboard = attrs['dashboard'] || parent.document.attr('grafana_default_dashboard')
44
+ @report.logger.debug("Processing PanelImageInlineMacro (instance: #{instance}, dashboard: #{dashboard}, panel: #{target})")
45
+ query = PanelImageQuery.new(@report.grafana(instance).dashboard(dashboard).panel(target))
46
+ query.merge_hash_variables(parent.document.attributes, attrs)
47
+ @report.logger.debug("from: #{query.from}, to: #{query.to}")
48
+
49
+ begin
50
+ image = query.execute(@report.grafana(instance))
51
+ image_path = @report.save_image_file(image)
52
+ rescue GrafanaReporterError => e
53
+ @report.logger.error(e.message)
54
+ return create_inline(parent, :quoted, e.message)
55
+ rescue StandardError => e
56
+ @report.logger.fatal(e.message)
57
+ return create_inline(parent, :quoted, e.message)
58
+ end
59
+
60
+ create_inline(parent, :image, nil, { target: image_path, attributes: attrs })
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,58 @@
1
+ require_relative 'processor_mixin'
2
+
3
+ module GrafanaReporter
4
+ module Asciidoctor
5
+ module Extensions
6
+ # Implements the hook
7
+ # grafana_panel_property:<panel_id>[<options>]
8
+ #
9
+ # Returns the requested panel property.
10
+ #
11
+ # == Used document parameters
12
+ # +grafana_default_instance+ - name of grafana instance, 'default' if not specified
13
+ #
14
+ # +grafana_default_dashboard+ - uid of grafana default dashboard to use
15
+ #
16
+ # == Supported options
17
+ # +field+ - property to query for, e.g. +description+ or +title+ (*mandatory*)
18
+ #
19
+ # +instance+ - name of grafana instance, 'default' if not specified
20
+ #
21
+ # +dashboard+ - uid of grafana dashboard to use
22
+ class PanelPropertyInlineMacro < ::Asciidoctor::Extensions::InlineMacroProcessor
23
+ include ProcessorMixin
24
+ use_dsl
25
+
26
+ named :grafana_panel_property
27
+ name_positional_attributes :field
28
+
29
+ # :nodoc:
30
+ def process(parent, target, attrs)
31
+ return if @report.cancel
32
+
33
+ @report.next_step
34
+ instance = attrs['instance'] || parent.document.attr('grafana_default_instance') || 'default'
35
+ dashboard = attrs['dashboard'] || parent.document.attr('grafana_default_dashboard')
36
+ @report.logger.debug("Processing PanelPropertyInlineMacro (instance: #{instance}, dashboard: #{dashboard}, panel: #{target}, property: #{attrs[:field]})")
37
+ query = PanelPropertyQuery.new(@report.grafana(instance).dashboard(dashboard).panel(target), attrs[:field])
38
+ query.merge_hash_variables(parent.document.attributes, attrs)
39
+ @report.logger.debug("from: #{query.from}, to: #{query.to}")
40
+
41
+ begin
42
+ description = query.execute(@report.grafana(instance))
43
+ rescue GrafanaReporterError => e
44
+ @report.logger.error(e.message)
45
+ return create_inline(parent, :quoted, e.message)
46
+ rescue StandardError => e
47
+ @report.logger.fatal(e.message)
48
+ return create_inline(parent, :quoted, e.message)
49
+ end
50
+
51
+ # translate linebreaks to asciidoctor syntax
52
+ # and HTML encode to make sure, that HTML formattings are respected
53
+ create_inline(parent, :quoted, CGI.escapeHTML(description.gsub(%r{//[^\n]*(?:\n)?}, '').gsub(/\n/, " +\n")))
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end