haml_lint 0.73.0 → 0.75.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +10 -5
  3. data/config/forced_rubocop_config.yml +13 -13
  4. data/lib/haml_lint/cli.rb +5 -1
  5. data/lib/haml_lint/document.rb +3 -8
  6. data/lib/haml_lint/exceptions.rb +2 -2
  7. data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +2 -2
  8. data/lib/haml_lint/haml_visitor.rb +1 -1
  9. data/lib/haml_lint/lint.rb +1 -1
  10. data/lib/haml_lint/linter/class_attribute_with_static_value.rb +51 -11
  11. data/lib/haml_lint/linter/classes_before_ids.rb +16 -1
  12. data/lib/haml_lint/linter/consecutive_comments.rb +20 -1
  13. data/lib/haml_lint/linter/empty_object_reference.rb +16 -1
  14. data/lib/haml_lint/linter/empty_script.rb +31 -1
  15. data/lib/haml_lint/linter/final_newline.rb +31 -7
  16. data/lib/haml_lint/linter/implicit_div.rb +14 -1
  17. data/lib/haml_lint/linter/leading_comment_space.rb +13 -1
  18. data/lib/haml_lint/linter/multiline_script.rb +65 -5
  19. data/lib/haml_lint/linter/rubocop.rb +10 -22
  20. data/lib/haml_lint/linter/ruby_comments.rb +14 -4
  21. data/lib/haml_lint/linter/space_before_script.rb +37 -4
  22. data/lib/haml_lint/linter/space_inside_hash_attributes.rb +41 -3
  23. data/lib/haml_lint/linter/tag_name.rb +14 -1
  24. data/lib/haml_lint/linter/trailing_empty_lines.rb +13 -1
  25. data/lib/haml_lint/linter/trailing_whitespace.rb +15 -2
  26. data/lib/haml_lint/linter/unescaped_html.rb +27 -0
  27. data/lib/haml_lint/linter/unnecessary_interpolation.rb +30 -3
  28. data/lib/haml_lint/linter/unnecessary_string_output.rb +35 -3
  29. data/lib/haml_lint/linter.rb +112 -1
  30. data/lib/haml_lint/node_transformer.rb +2 -2
  31. data/lib/haml_lint/reporter/disabled_config_reporter.rb +26 -5
  32. data/lib/haml_lint/reporter/github_reporter.rb +26 -8
  33. data/lib/haml_lint/ruby_extraction/base_chunk.rb +3 -3
  34. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +37 -13
  35. data/lib/haml_lint/ruby_extraction/coordinator.rb +2 -2
  36. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +1 -1
  37. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +1 -1
  38. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +1 -1
  39. data/lib/haml_lint/ruby_extraction/script_chunk.rb +1 -1
  40. data/lib/haml_lint/runner.rb +31 -3
  41. data/lib/haml_lint/source.rb +2 -6
  42. data/lib/haml_lint/spec/matchers/report_lint.rb +2 -2
  43. data/lib/haml_lint/tree/comment_node.rb +1 -1
  44. data/lib/haml_lint/tree/doctype_node.rb +1 -1
  45. data/lib/haml_lint/tree/filter_node.rb +14 -1
  46. data/lib/haml_lint/tree/haml_comment_node.rb +2 -2
  47. data/lib/haml_lint/tree/node.rb +3 -3
  48. data/lib/haml_lint/tree/root_node.rb +2 -2
  49. data/lib/haml_lint/tree/silent_script_node.rb +1 -1
  50. data/lib/haml_lint/tree/tag_node.rb +57 -25
  51. data/lib/haml_lint/utils.rb +1 -1
  52. data/lib/haml_lint/version.rb +1 -1
  53. metadata +3 -2
@@ -5,6 +5,9 @@ module HamlLint
5
5
  class Linter::MultilineScript < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+ autocorrect_safe(false)
10
+
8
11
  # List of operators that can split a script into two lines that we want to
9
12
  # alert on.
10
13
  SPLIT_OPERATORS = %w[
@@ -31,8 +34,40 @@ module HamlLint
31
34
  check(node)
32
35
  end
33
36
 
37
+ def after_visit_root(node)
38
+ super
39
+ return if merges.empty?
40
+
41
+ apply_autocorrect(merged_source)
42
+ end
43
+
34
44
  private
35
45
 
46
+ def reset_autocorrect_state
47
+ super
48
+ @merges = []
49
+ end
50
+
51
+ def merged_source
52
+ lines = document.source_lines.dup
53
+ to_delete = apply_merges(lines)
54
+ lines.reject.with_index { |_, index| to_delete.include?(index) }.join("\n")
55
+ end
56
+
57
+ # Mutates +lines+ in place, folding each continuation script onto its chain
58
+ # root, and returns the line indices that should be deleted.
59
+ #
60
+ # @return [Array<Integer>]
61
+ def apply_merges(lines)
62
+ redirect = {}
63
+ merges.sort_by { |merge| merge[:from] }.map do |merge|
64
+ root = redirect.fetch(merge[:from], merge[:from])
65
+ redirect[merge[:succ_line]] = root
66
+ lines[root] = "#{lines[root].rstrip} #{merge[:succ_script]}"
67
+ merge[:succ_line]
68
+ end
69
+ end
70
+
36
71
  def check(node)
37
72
  # Condition occurs when scripts do not contain nested content, e.g.
38
73
  #
@@ -48,11 +83,36 @@ module HamlLint
48
83
  return unless node.children.empty?
49
84
 
50
85
  operator = node.script[/\s+(\S+)\z/, 1]
51
- if SPLIT_OPERATORS.include?(operator)
52
- record_lint(node,
53
- "Script with trailing operator `#{operator}` should be " \
54
- 'merged with the script on the following line')
55
- end
86
+ return unless SPLIT_OPERATORS.include?(operator)
87
+
88
+ corrected = collect_merge(node)
89
+ record_lint(node,
90
+ "Script with trailing operator `#{operator}` should be " \
91
+ 'merged with the script on the following line',
92
+ corrected: corrected)
93
+ end
94
+
95
+ def collect_merge(node)
96
+ return false unless autocorrect?
97
+
98
+ # Only merge with the immediately following sibling on the next line.
99
+ # `node.successor` would climb to an ancestor's sibling when this script is
100
+ # the last child of its block, which would pull a line from outside the
101
+ # block into it and change the semantics.
102
+ succ = node.subsequents.first
103
+ return false unless succ && %i[script silent_script].include?(succ.type)
104
+ return false unless succ.line == node.line + 1
105
+
106
+ merges << {
107
+ from: node.line - 1,
108
+ succ_line: succ.line - 1,
109
+ succ_script: succ.script.strip,
110
+ }
111
+ true
112
+ end
113
+
114
+ def merges
115
+ @merges ||= []
56
116
  end
57
117
  end
58
118
  end
@@ -4,14 +4,14 @@ require 'rubocop'
4
4
  require 'tempfile'
5
5
 
6
6
  module HamlLint
7
- # Runs RuboCop on the Ruby code contained within HAML templates.
7
+ # Runs RuboCop on the Ruby code contained within Haml templates.
8
8
  #
9
9
  # The processing is done by extracting a Ruby file that matches the content, including
10
- # the indentation, of the HAML file. This way, we can run RuboCop with autocorrect
11
- # and get new Ruby code which should be HAML compatible.
10
+ # the indentation, of the Haml file. This way, we can run RuboCop with autocorrect
11
+ # and get new Ruby code which should be Haml compatible.
12
12
  #
13
- # The ruby extraction makes "Chunks" which wrap each HAML constructs. The Chunks can then
14
- # use the corrected Ruby code to apply the corrections back in the HAML using logic specific
13
+ # The ruby extraction makes "Chunks" which wrap each Haml constructs. The Chunks can then
14
+ # use the corrected Ruby code to apply the corrections back in the Haml using logic specific
15
15
  # to each type of Chunk.
16
16
  #
17
17
  # The work is spread across the classes in the HamlLint::RubyExtraction module.
@@ -20,10 +20,12 @@ module HamlLint
20
20
  class Runner < ::RuboCop::Runner
21
21
  attr_reader :offenses
22
22
 
23
- def run(haml_path, ruby_code, config:, allow_cache: false)
24
- @allow_cache = allow_cache
23
+ def run(haml_path, ruby_code, config:)
25
24
  @offenses = []
26
25
  @config_store.instance_variable_set(:@options_config, config)
26
+ # Using stdin also disables RuboCop's result cache, which is intentional:
27
+ # it reconstructs offense positions from the on-disk Haml, not the Ruby we
28
+ # inspected, so reusing it misreports lines (sds/haml-lint#593).
27
29
  @options[:stdin] = ruby_code
28
30
  super([haml_path])
29
31
  end
@@ -40,20 +42,6 @@ module HamlLint
40
42
  def file_finished(_file, offenses)
41
43
  @offenses = offenses
42
44
  end
43
-
44
- # RuboCop caches results by taking a hash of the file contents & path, among other things.
45
- # It disables its cache when working on file-content from stdin.
46
- # Unfortunately we always use RuboCop's stdin, even when we're linting a file on-disk.
47
- # So, override RuboCop::Runner#cached_run? so that it'll allow caching results, so long
48
- # as haml-lint itself isn't being invoked with files on stdin.
49
- def cached_run?
50
- return false unless @allow_cache
51
-
52
- @cached_run ||=
53
- (@options[:cache] == 'true' ||
54
- (@options[:cache] != 'false' && @config_store.for_pwd.for_all_cops['UseCache'])) &&
55
- !@options[:auto_gen_config]
56
- end
57
45
  end
58
46
 
59
47
  include LinterRegistry
@@ -242,7 +230,7 @@ module HamlLint
242
230
  # @param path [String] the path to tell RuboCop we are running
243
231
  # @return [Array<RuboCop::Cop::Offense>, String]
244
232
  def run_rubocop(rubocop_runner, ruby_code, path) # rubocop:disable Metrics
245
- rubocop_runner.run(path, ruby_code, config: rubocop_config_for(path), allow_cache: @document&.file_on_disk)
233
+ rubocop_runner.run(path, ruby_code, config: rubocop_config_for(path))
246
234
 
247
235
  if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
248
236
  if rubocop_runner.offenses.empty?
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HamlLint
4
- # Checks for Ruby comments that can be written as HAML comments.
4
+ # Checks for Ruby comments that can be written as Haml comments.
5
5
  class Linter::RubyComments < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_silent_script(node)
9
- if code_comment?(node)
10
- record_lint(node, 'Use `-#` for comments instead of `- #`')
11
- end
11
+ return unless code_comment?(node)
12
+
13
+ corrected = correct_comment(node)
14
+ record_lint(node, 'Use `-#` for comments instead of `- #`', corrected: corrected)
12
15
  end
13
16
 
14
17
  private
@@ -16,5 +19,12 @@ module HamlLint
16
19
  def code_comment?(node)
17
20
  node.script =~ /\A\s+#/
18
21
  end
22
+
23
+ # @return [Boolean]
24
+ def correct_comment(node)
25
+ index = node.line - 1
26
+ line = autocorrected_lines[index]
27
+ correct_line(index, line.sub(/\A(\s*)-\s+#/, '\1-#'))
28
+ end
19
29
  end
20
30
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HamlLint
4
- # Checks for Ruby script in HAML templates with no space after the `=`/`-`.
4
+ # Checks for Ruby script in Haml templates with no space after the `=`/`-`.
5
5
  class Linter::SpaceBeforeScript < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  MESSAGE_FORMAT = 'The %s symbol should have one space separating it from code'
9
11
 
10
12
  ALLOWED_SEPARATORS = [' ', '#'].freeze
@@ -33,18 +35,23 @@ module HamlLint
33
35
  # (need to do it this way as the parser strips whitespace from node)
34
36
  return unless tag_with_text[index - 1] != ' '
35
37
 
36
- record_lint(node, MESSAGE_FORMAT % '=')
38
+ corrected = correct_inline_script(node, text)
39
+ record_lint(node, MESSAGE_FORMAT % '=', corrected: corrected)
37
40
  end
38
41
 
39
42
  def visit_script(node)
40
43
  # Plain text nodes with interpolation are converted to script nodes, so we
41
44
  # need to ignore them here.
42
45
  return unless document.source_lines[node.line - 1].lstrip.start_with?('=')
43
- record_lint(node, MESSAGE_FORMAT % '=') if missing_space?(node)
46
+ return unless missing_space?(node)
47
+
48
+ record_lint(node, MESSAGE_FORMAT % '=', corrected: correct_leading_marker(node, '='))
44
49
  end
45
50
 
46
51
  def visit_silent_script(node)
47
- record_lint(node, MESSAGE_FORMAT % '-') if missing_space?(node)
52
+ return unless missing_space?(node)
53
+
54
+ record_lint(node, MESSAGE_FORMAT % '-', corrected: correct_leading_marker(node, '-'))
48
55
  end
49
56
 
50
57
  private
@@ -53,5 +60,31 @@ module HamlLint
53
60
  text = node.script
54
61
  !ALLOWED_SEPARATORS.include?(text[0]) if text
55
62
  end
63
+
64
+ # Inserts one space after a leading `=`/`-` marker.
65
+ #
66
+ # @return [Boolean]
67
+ def correct_leading_marker(node, marker)
68
+ index = node.line - 1
69
+ line = autocorrected_lines[index]
70
+ escaped = Regexp.escape(marker)
71
+ correct_line(index, line.sub(/\A(\s*#{escaped})(?=[^\s#{escaped}])/, '\1 '))
72
+ end
73
+
74
+ # Inserts a space after the `=` marker introducing a tag's inline script.
75
+ #
76
+ # @return [Boolean]
77
+ def correct_inline_script(node, text)
78
+ index = node.line - 1
79
+ line = autocorrected_lines[index]
80
+
81
+ pos = line.rindex("=#{text}")
82
+ if pos.nil? && (unquoted = strip_surrounding_quotes(text))
83
+ pos = line.rindex("=#{unquoted}")
84
+ end
85
+ return false unless pos
86
+
87
+ correct_line(index, "#{line[0..pos]} #{line[(pos + 1)..]}")
88
+ end
56
89
  end
57
90
  end
@@ -6,6 +6,8 @@ module HamlLint
6
6
  class Linter::SpaceInsideHashAttributes < Linter
7
7
  include LinterRegistry
8
8
 
9
+ supports_autocorrect(true)
10
+
9
11
  STYLE = {
10
12
  'no_space' => {
11
13
  start_regex: /\A\{[^ ]/,
@@ -24,11 +26,47 @@ module HamlLint
24
26
  def visit_tag(node)
25
27
  return unless node.hash_attributes?
26
28
 
27
- style = STYLE[config['style'] == 'no_space' ? 'no_space' : 'space']
29
+ style_name = config['style'] == 'no_space' ? 'no_space' : 'space'
30
+ style = STYLE[style_name]
28
31
  source = node.hash_attributes_source
29
32
 
30
- record_lint(node, style[:start_message]) unless source&.match?(style[:start_regex])
31
- record_lint(node, style[:end_message]) unless source&.match?(style[:end_regex])
33
+ start_ok = source.match?(style[:start_regex])
34
+ end_ok = source.match?(style[:end_regex])
35
+ return if start_ok && end_ok
36
+
37
+ corrected = correct_hash_spacing(node, source, style_name)
38
+ record_lint(node, style[:start_message], corrected: corrected) unless start_ok
39
+ record_lint(node, style[:end_message], corrected: corrected) unless end_ok
40
+ end
41
+
42
+ private
43
+
44
+ # @return [Boolean]
45
+ def correct_hash_spacing(node, source, style_name)
46
+ return false unless source
47
+ return false if source.include?("\n") # multi-line hash: detection-only
48
+
49
+ index = node.line - 1
50
+ line = autocorrected_lines[index]
51
+ return false unless line.include?(source)
52
+
53
+ fixed = corrected_hash_source(source, style_name)
54
+ return false if fixed == source
55
+
56
+ correct_line(index, line.sub(source) { fixed })
57
+ end
58
+
59
+ # @return [String]
60
+ def corrected_hash_source(source, style_name)
61
+ inner = source[1...-1].strip
62
+
63
+ if style_name == 'no_space'
64
+ "{#{inner}}"
65
+ elsif inner.empty?
66
+ '{ }'
67
+ else
68
+ "{ #{inner} }"
69
+ end
32
70
  end
33
71
  end
34
72
  end
@@ -5,11 +5,24 @@ module HamlLint
5
5
  class Linter::TagName < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_tag(node)
9
11
  tag = node.tag_name
10
12
  return unless /[A-Z]/.match?(tag)
11
13
 
12
- record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`")
14
+ corrected = correct_tag_name(node, tag)
15
+ record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`",
16
+ corrected: corrected)
17
+ end
18
+
19
+ private
20
+
21
+ # @return [Boolean]
22
+ def correct_tag_name(node, tag)
23
+ index = node.line - 1
24
+ line = autocorrected_lines[index]
25
+ correct_line(index, line.sub(/(%)#{Regexp.escape(tag)}/, "\\1#{tag.downcase}"))
13
26
  end
14
27
  end
15
28
  end
@@ -5,6 +5,8 @@ module HamlLint
5
5
  class Linter::TrailingEmptyLines < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  DummyNode = Struct.new(:line)
9
11
 
10
12
  def visit_root(root)
@@ -16,7 +18,17 @@ module HamlLint
16
18
 
17
19
  return unless document.source.end_with?("\n\n")
18
20
 
19
- record_lint(line_number, 'Files should not end with trailing empty lines')
21
+ record_lint(line_number, 'Files should not end with trailing empty lines',
22
+ corrected: autocorrect?)
23
+
24
+ apply_autocorrect(corrected_source)
25
+ end
26
+
27
+ private
28
+
29
+ def corrected_source
30
+ last_non_empty_index = document.source_lines.rindex { |line| !line.empty? } || 0
31
+ "#{document.source_lines[0..last_non_empty_index].join("\n")}\n"
20
32
  end
21
33
  end
22
34
  end
@@ -5,17 +5,30 @@ module HamlLint
5
5
  class Linter::TrailingWhitespace < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  DummyNode = Struct.new(:line)
9
11
 
10
12
  def visit_root(root)
13
+ new_lines = document.source_lines.dup
14
+ changed = false
15
+
11
16
  document.source_lines.each_with_index do |line, index|
12
17
  next unless /\s+$/.match?(line)
13
18
 
14
19
  node = root.node_for_line(index + 1)
15
- unless node.disabled?(self)
16
- record_lint DummyNode.new(index + 1), 'Line contains trailing whitespace'
20
+ next if node.disabled?(self)
21
+
22
+ if autocorrect?
23
+ new_lines[index] = line.sub(/\s+$/, '')
24
+ changed = true
17
25
  end
26
+
27
+ record_lint(DummyNode.new(index + 1), 'Line contains trailing whitespace',
28
+ corrected: autocorrect?)
18
29
  end
30
+
31
+ apply_autocorrect(new_lines.join("\n")) if changed
19
32
  end
20
33
  end
21
34
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint
4
+ # Flags Haml's unescaped-output markers (`!=`, `!~`, and the unescaped
5
+ # plain-text `!`), which bypass HTML escaping.
6
+ #
7
+ # Like `raw`, `html_safe`, and `h()` in Rails, these make it easy to
8
+ # accidentally introduce XSS vulnerabilities when the output includes
9
+ # user-controlled data, e.g.:
10
+ #
11
+ # != "Username: <strong>#{user.name}</strong>"
12
+ class Linter::UnescapedHtml < Linter
13
+ include LinterRegistry
14
+
15
+ MESSAGE =
16
+ 'Avoid outputting unescaped HTML with `!`; it bypasses HTML escaping and ' \
17
+ 'can introduce XSS vulnerabilities. Sanitize the value instead.'
18
+
19
+ def visit_script(node)
20
+ record_lint(node, MESSAGE) if /\A\s*!/.match?(node.source_code)
21
+ end
22
+
23
+ def visit_tag(node)
24
+ record_lint(node, MESSAGE) if node.unescape_html?
25
+ end
26
+ end
27
+ end
@@ -11,21 +11,48 @@ module HamlLint
11
11
  class Linter::UnnecessaryInterpolation < Linter
12
12
  include LinterRegistry
13
13
 
14
+ supports_autocorrect(true)
15
+
14
16
  def visit_tag(node)
15
17
  return if node.script.length <= 2
16
18
 
17
19
  count = 0
18
20
  chars = 2 # Include surrounding quote chars
19
- HamlLint::Utils.extract_interpolated_values(node.script) do |interpolated_code, _line|
21
+ interpolated_code = nil
22
+ HamlLint::Utils.extract_interpolated_values(node.script) do |code, _line|
20
23
  count += 1
21
24
  return if count > 1 # rubocop:disable Lint/NonLocalExitFromIterator
22
- chars += interpolated_code.length + 3
25
+ chars += code.length + 3
26
+ interpolated_code = code
23
27
  end
24
28
 
25
29
  if chars == node.script.length
30
+ corrected = correct_interpolation(node, interpolated_code)
26
31
  record_lint(node, '`%... \#{expression}` can be written without ' \
27
- 'interpolation as `%...= expression`')
32
+ 'interpolation as `%...= expression`', corrected: corrected)
28
33
  end
29
34
  end
35
+
36
+ private
37
+
38
+ # @return [Boolean]
39
+ def correct_interpolation(node, interpolated_code)
40
+ return false unless interpolated_code
41
+
42
+ index = node.line - 1
43
+ line = autocorrected_lines[index]
44
+ escaped = Regexp.escape(interpolated_code)
45
+
46
+ new_line =
47
+ if inline_content_is_string?(node)
48
+ line.sub(/=\s*"\#\{#{escaped}\}"\s*\z/, "= #{interpolated_code}")
49
+ else
50
+ line.sub(/\s+\#\{#{escaped}\}\s*\z/, "= #{interpolated_code}")
51
+ end
52
+
53
+ return false if new_line == line
54
+
55
+ correct_line(index, new_line)
56
+ end
30
57
  end
31
58
  end
@@ -20,7 +20,7 @@ module HamlLint
20
20
  end
21
21
 
22
22
  def visit_script(node)
23
- # Some script nodes created by the HAML parser aren't actually script
23
+ # Some script nodes created by the Haml parser aren't actually script
24
24
  # nodes declared via the `=` marker. Check for it.
25
25
  return unless /\A\s*=/.match?(node.source_code)
26
26
 
@@ -33,8 +33,11 @@ module HamlLint
33
33
 
34
34
  def outputs_string_literal?(script_node)
35
35
  return unless tree = parse_ruby(script_node.script)
36
- %i[str dstr].include?(tree.type) &&
37
- !starts_with_reserved_character?(tree.children.first)
36
+ return unless %i[str dstr].include?(tree.type)
37
+
38
+ !starts_with_reserved_character?(tree.children.first) &&
39
+ !contains_escape_sequence?(tree) &&
40
+ !contains_significant_whitespace?(tree)
38
41
  rescue ::Parser::SyntaxError
39
42
  # Gracefully ignore syntax errors, as that's managed by a different linter
40
43
  end
@@ -45,5 +48,34 @@ module HamlLint
45
48
  string = stringish.respond_to?(:children) ? stringish.children.first : stringish
46
49
  string =~ %r{\A\s*[/#-=%~]} if string.is_a?(String)
47
50
  end
51
+
52
+ # The ordered segments of a string literal, including any interpolation.
53
+ # A plain `str` node has no interpolation, so it is its own only segment.
54
+ def string_segments(tree)
55
+ tree.type == :dstr ? tree.children : [tree]
56
+ end
57
+
58
+ # Returns whether any literal portion of the string contains a backslash
59
+ # escape (e.g. `\n`, `\t`, `\u202F`). Such escapes are interpreted inside
60
+ # a Ruby string but would be emitted verbatim as Haml plain text, so the
61
+ # `= "..."` form is not equivalent to the unwrapped plain text.
62
+ def contains_escape_sequence?(tree)
63
+ string_segments(tree).any? do |segment|
64
+ segment.type == :str && segment.location.expression.source.include?('\\')
65
+ end
66
+ end
67
+
68
+ # Returns whether the string begins or ends with whitespace. Haml strips
69
+ # trailing whitespace from plain text (and leading whitespace denotes
70
+ # indentation), so unwrapping such a string would change the output.
71
+ def contains_significant_whitespace?(tree)
72
+ segments = string_segments(tree)
73
+ bounded_by_whitespace?(segments.first, /\A\s/) ||
74
+ bounded_by_whitespace?(segments.last, /\s\z/)
75
+ end
76
+
77
+ def bounded_by_whitespace?(segment, pattern)
78
+ segment.type == :str && segment.children.first.to_s.match?(pattern)
79
+ end
48
80
  end
49
81
  end