theme-check 0.10.2 → 1.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +2 -6
  3. data/CHANGELOG.md +51 -0
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +39 -0
  6. data/RELEASING.md +34 -2
  7. data/bin/theme-check +29 -0
  8. data/bin/theme-check-language-server +29 -0
  9. data/config/default.yml +46 -3
  10. data/config/nothing.yml +11 -0
  11. data/config/theme_app_extension.yml +168 -0
  12. data/data/shopify_liquid/objects.yml +2 -0
  13. data/docs/checks/app_block_valid_tags.md +40 -0
  14. data/docs/checks/asset_size_app_block_css.md +52 -0
  15. data/docs/checks/asset_size_app_block_javascript.md +57 -0
  16. data/docs/checks/asset_size_css_stylesheet_tag.md +50 -0
  17. data/docs/checks/deprecate_bgsizes.md +66 -0
  18. data/docs/checks/deprecate_lazysizes.md +61 -0
  19. data/docs/checks/liquid_tag.md +2 -2
  20. data/docs/checks/missing_template.md +25 -0
  21. data/docs/checks/pagination_size.md +44 -0
  22. data/docs/checks/template_length.md +12 -2
  23. data/docs/checks/undefined_object.md +5 -0
  24. data/lib/theme_check/analyzer.rb +25 -21
  25. data/lib/theme_check/asset_file.rb +3 -15
  26. data/lib/theme_check/bug.rb +3 -1
  27. data/lib/theme_check/check.rb +26 -4
  28. data/lib/theme_check/checks/app_block_valid_tags.rb +36 -0
  29. data/lib/theme_check/checks/asset_size_app_block_css.rb +44 -0
  30. data/lib/theme_check/checks/asset_size_app_block_javascript.rb +44 -0
  31. data/lib/theme_check/checks/asset_size_css.rb +11 -74
  32. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +24 -0
  33. data/lib/theme_check/checks/asset_size_javascript.rb +11 -37
  34. data/lib/theme_check/checks/convert_include_to_render.rb +3 -1
  35. data/lib/theme_check/checks/deprecate_bgsizes.rb +14 -0
  36. data/lib/theme_check/checks/deprecate_lazysizes.rb +16 -0
  37. data/lib/theme_check/checks/img_lazy_loading.rb +2 -7
  38. data/lib/theme_check/checks/img_width_and_height.rb +3 -3
  39. data/lib/theme_check/checks/liquid_tag.rb +2 -2
  40. data/lib/theme_check/checks/missing_template.rb +21 -5
  41. data/lib/theme_check/checks/pagination_size.rb +65 -0
  42. data/lib/theme_check/checks/parser_blocking_javascript.rb +1 -1
  43. data/lib/theme_check/checks/remote_asset.rb +4 -2
  44. data/lib/theme_check/checks/space_inside_braces.rb +27 -7
  45. data/lib/theme_check/checks/template_length.rb +18 -4
  46. data/lib/theme_check/checks/undefined_object.rb +1 -1
  47. data/lib/theme_check/checks/valid_html_translation.rb +1 -1
  48. data/lib/theme_check/checks.rb +11 -1
  49. data/lib/theme_check/cli.rb +52 -15
  50. data/lib/theme_check/config.rb +56 -10
  51. data/lib/theme_check/corrector.rb +4 -0
  52. data/lib/theme_check/exceptions.rb +29 -27
  53. data/lib/theme_check/file_system_storage.rb +12 -0
  54. data/lib/theme_check/html_check.rb +1 -0
  55. data/lib/theme_check/html_node.rb +37 -16
  56. data/lib/theme_check/html_visitor.rb +17 -3
  57. data/lib/theme_check/json_check.rb +2 -2
  58. data/lib/theme_check/json_file.rb +2 -29
  59. data/lib/theme_check/json_printer.rb +26 -0
  60. data/lib/theme_check/language_server/constants.rb +8 -0
  61. data/lib/theme_check/language_server/document_link_engine.rb +40 -4
  62. data/lib/theme_check/language_server/handler.rb +6 -2
  63. data/lib/theme_check/language_server/server.rb +13 -2
  64. data/lib/theme_check/liquid_check.rb +0 -12
  65. data/lib/theme_check/node.rb +6 -4
  66. data/lib/theme_check/offense.rb +56 -3
  67. data/lib/theme_check/parsing_helpers.rb +7 -4
  68. data/lib/theme_check/position.rb +98 -14
  69. data/lib/theme_check/regex_helpers.rb +20 -0
  70. data/lib/theme_check/tags.rb +62 -8
  71. data/lib/theme_check/template.rb +3 -32
  72. data/lib/theme_check/theme.rb +2 -0
  73. data/lib/theme_check/theme_file.rb +40 -0
  74. data/lib/theme_check/version.rb +1 -1
  75. data/lib/theme_check.rb +16 -0
  76. data/theme-check.gemspec +1 -1
  77. metadata +26 -7
  78. data/bin/liquid-server +0 -4
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
- require "nokogumbo"
2
+ require "nokogiri"
3
3
  require "forwardable"
4
4
 
5
5
  module ThemeCheck
6
6
  class HtmlVisitor
7
+ include RegexHelpers
7
8
  attr_reader :checks
8
9
 
9
10
  def initialize(checks)
10
11
  @checks = checks
12
+ @placeholder_values = []
11
13
  end
12
14
 
13
15
  def visit_template(template)
14
16
  doc = parse(template)
15
- visit(HtmlNode.new(doc, template))
17
+ visit(HtmlNode.new(doc, template, @placeholder_values))
16
18
  rescue ArgumentError => e
17
19
  call_checks(:on_parse_error, e, template)
18
20
  end
@@ -20,7 +22,19 @@ module ThemeCheck
20
22
  private
21
23
 
22
24
  def parse(template)
23
- Nokogiri::HTML5.fragment(template.source, max_tree_depth: 400, max_attributes: 400)
25
+ parseable_source = +template.source.clone
26
+
27
+ # Replace all liquid tags with {%#{i}######%} to prevent the HTML
28
+ # parser from freaking out. We transparently replace those placeholders in
29
+ # HtmlNode.
30
+ matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
+ value = m[0]
32
+ @placeholder_values.push(value)
33
+ key = (@placeholder_values.size - 1).to_s
34
+ parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
35
+ end
36
+
37
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
24
38
  end
25
39
 
26
40
  def visit(node)
@@ -4,8 +4,8 @@ module ThemeCheck
4
4
  class JsonCheck < Check
5
5
  extend ChecksTracking
6
6
 
7
- def add_offense(message, markup: nil, line_number: nil, template: nil)
8
- offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template)
7
+ def add_offense(message, markup: nil, line_number: nil, template: nil, &block)
8
+ offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template, correction: block)
9
9
  end
10
10
  end
11
11
  end
@@ -1,29 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
- require "pathname"
4
3
 
5
4
  module ThemeCheck
6
- class JsonFile
5
+ class JsonFile < ThemeFile
7
6
  def initialize(relative_path, storage)
8
- @relative_path = relative_path
9
- @storage = storage
7
+ super
10
8
  @loaded = false
11
9
  @content = nil
12
10
  @parser_error = nil
13
11
  end
14
12
 
15
- def path
16
- @storage.path(@relative_path)
17
- end
18
-
19
- def relative_path
20
- @relative_pathname ||= Pathname.new(@relative_path)
21
- end
22
-
23
- def source
24
- @source ||= @storage.read(@relative_path)
25
- end
26
-
27
13
  def content
28
14
  load!
29
15
  @content
@@ -34,23 +20,10 @@ module ThemeCheck
34
20
  @parser_error
35
21
  end
36
22
 
37
- def name
38
- relative_path.sub_ext('').to_s
39
- end
40
-
41
23
  def json?
42
24
  true
43
25
  end
44
26
 
45
- def liquid?
46
- false
47
- end
48
-
49
- def ==(other)
50
- other.is_a?(JsonFile) && relative_path == other.relative_path
51
- end
52
- alias_method :eql?, :==
53
-
54
27
  private
55
28
 
56
29
  def load!
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module ThemeCheck
5
+ class JsonPrinter
6
+ def print(offenses)
7
+ json = offenses_by_path(offenses)
8
+ puts JSON.dump(json)
9
+ end
10
+
11
+ def offenses_by_path(offenses)
12
+ offenses
13
+ .map(&:to_h)
14
+ .group_by { |offense| offense[:path] }
15
+ .map do |(path, path_offenses)|
16
+ {
17
+ path: path,
18
+ offenses: path_offenses.map { |offense| offense.filter { |k, _v| k != :path } },
19
+ errorCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:error] },
20
+ suggestionCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:suggestion] },
21
+ styleCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:style] },
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -10,5 +10,13 @@ module ThemeCheck
10
10
  ^\s*render\s+'(?<partial>[^']*)'|
11
11
  ^\s*render\s+"(?<partial>[^"]*)"
12
12
  }mix
13
+ ASSET_INCLUDE = %r{
14
+ \{\%-?\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
15
+ \{\%-?\s*"(?<partial>[^"]*)"\s*\|\s*asset_url|
16
+
17
+ # in liquid tags the whole line is white space until the asset partial
18
+ ^\s*'(?<partial>[^']*)'\s*\|\s*asset_url|
19
+ ^\s*"(?<partial>[^"]*)"\s*\|\s*asset_url
20
+ }mix
13
21
  end
14
22
  end
@@ -13,7 +13,7 @@ module ThemeCheck
13
13
  def document_links(relative_path)
14
14
  buffer = @storage.read(relative_path)
15
15
  return [] unless buffer
16
- matches(buffer, PARTIAL_RENDER).map do |match|
16
+ snippet_matches = matches(buffer, PARTIAL_RENDER).map do |match|
17
17
  start_line, start_character = from_index_to_row_column(
18
18
  buffer,
19
19
  match.begin(:partial),
@@ -25,7 +25,7 @@ module ThemeCheck
25
25
  )
26
26
 
27
27
  {
28
- target: link(match[:partial]),
28
+ target: snippet_link(match[:partial]),
29
29
  range: {
30
30
  start: {
31
31
  line: start_line,
@@ -38,10 +38,46 @@ module ThemeCheck
38
38
  },
39
39
  }
40
40
  end
41
+ asset_matches = matches(buffer, ASSET_INCLUDE).map do |match|
42
+ start_line, start_character = from_index_to_row_column(
43
+ buffer,
44
+ match.begin(:partial),
45
+ )
46
+
47
+ end_line, end_character = from_index_to_row_column(
48
+ buffer,
49
+ match.end(:partial)
50
+ )
51
+
52
+ {
53
+ target: asset_link(match[:partial]),
54
+ range: {
55
+ start: {
56
+ line: start_line,
57
+ character: start_character,
58
+ },
59
+ end: {
60
+ line: end_line,
61
+ character: end_character,
62
+ },
63
+ },
64
+ }
65
+ end
66
+ snippet_matches + asset_matches
67
+ end
68
+
69
+ def snippet_link(partial)
70
+ file_link('snippets', partial, '.liquid')
41
71
  end
42
72
 
43
- def link(partial)
44
- "file://#{@storage.path('snippets/' + partial + '.liquid')}"
73
+ def asset_link(partial)
74
+ file_link('assets', partial, '')
75
+ end
76
+
77
+ private
78
+
79
+ def file_link(directory, partial, extension)
80
+ "file://#{@storage.path(directory + '/' + partial + extension)}"
45
81
  end
46
82
  end
47
83
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "benchmark"
3
+ require "uri"
4
+ require "cgi"
3
5
 
4
6
  module ThemeCheck
5
7
  module LanguageServer
@@ -98,8 +100,10 @@ module ThemeCheck
98
100
  path_from_uri(params.dig('textDocument', 'uri'))
99
101
  end
100
102
 
101
- def path_from_uri(uri)
102
- uri&.sub('file://', '')
103
+ def path_from_uri(uri_string)
104
+ return if uri_string.nil?
105
+ uri = URI(uri_string)
106
+ CGI.unescape(uri.path)
103
107
  end
104
108
 
105
109
  def relative_path_from_text_document_uri(params)
@@ -52,8 +52,19 @@ module ThemeCheck
52
52
  response_body = JSON.dump(response)
53
53
  log(JSON.pretty_generate(response)) if $DEBUG
54
54
 
55
- @out.write("Content-Length: #{response_body.bytesize}\r\n")
56
- @out.write("\r\n")
55
+ # Because programming is fun,
56
+ #
57
+ # Ruby on Windows turns \n into \r\n. Which means that \r\n
58
+ # gets turned into \r\r\n. Which means that the protocol
59
+ # breaks on windows unless we turn STDOUT into binary mode and
60
+ # set the encoding manually (yuk!) or we do this little hack
61
+ # here and put \n which gets transformed into \r\n on windows
62
+ # only...
63
+ #
64
+ # Hours wasted: 8.
65
+ eol = Gem.win_platform? ? "\n" : "\r\n"
66
+ @out.write("Content-Length: #{response_body.bytesize}#{eol}")
67
+ @out.write(eol)
57
68
  @out.write(response_body)
58
69
  @out.flush
59
70
  end
@@ -5,17 +5,5 @@ module ThemeCheck
5
5
  class LiquidCheck < Check
6
6
  extend ChecksTracking
7
7
  include ParsingHelpers
8
-
9
- # TODO: remove this once all regex checks are migrate to HtmlCheck# TODO: remove this once all regex checks are migrate to HtmlCheck
10
- TAG = /#{Liquid::TagStart}.*?#{Liquid::TagEnd}/om
11
- VARIABLE = /#{Liquid::VariableStart}.*?#{Liquid::VariableEnd}/om
12
- START_OR_END_QUOTE = /(^['"])|(['"]$)/
13
- QUOTED_LIQUID_ATTRIBUTE = %r{
14
- '(?:#{TAG}|#{VARIABLE}|[^'])*'| # any combination of tag/variable or non straight quote inside straight quotes
15
- "(?:#{TAG}|#{VARIABLE}|[^"])*" # any combination of tag/variable or non double quotes inside double quotes
16
- }omix
17
- ATTR = /[a-z0-9-]+/i
18
- HTML_ATTRIBUTE = /#{ATTR}(?:=#{QUOTED_LIQUID_ATTRIBUTE})?/omix
19
- HTML_ATTRIBUTES = /(?:#{HTML_ATTRIBUTE}|\s)*/omix
20
8
  end
21
9
  end
@@ -22,9 +22,7 @@ module ThemeCheck
22
22
  end
23
23
 
24
24
  def markup=(markup)
25
- if tag?
26
- @value.raw = markup
27
- elsif @value.instance_variable_defined?(:@markup)
25
+ if @value.instance_variable_defined?(:@markup)
28
26
  @value.instance_variable_set(:@markup, markup)
29
27
  end
30
28
  end
@@ -127,7 +125,11 @@ module ThemeCheck
127
125
  end
128
126
 
129
127
  def position
130
- @position ||= Position.new(markup, template&.source, line_number)
128
+ @position ||= Position.new(
129
+ markup,
130
+ template&.source,
131
+ line_number_1_indexed: line_number
132
+ )
131
133
  end
132
134
 
133
135
  def start_index
@@ -1,11 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
  module ThemeCheck
3
3
  class Offense
4
+ include PositionHelper
5
+
4
6
  MAX_SOURCE_EXCERPT_SIZE = 120
5
7
 
6
8
  attr_reader :check, :message, :template, :node, :markup, :line_number, :correction
7
9
 
8
- def initialize(check:, message: nil, template: nil, node: nil, markup: nil, line_number: nil, correction: nil)
10
+ def initialize(
11
+ check:, # instance of a ThemeCheck::Check
12
+ message: nil, # error message for the offense
13
+ template: nil, # Template
14
+ node: nil, # Node or HtmlNode
15
+ markup: nil, # string
16
+ line_number: nil, # line number of the error (1-indexed)
17
+ # node_markup_offset is the index inside node.markup to start
18
+ # looking for markup :mindblow:.
19
+ # This is so we can accurately highlight node substrings.
20
+ # e.g. if we have the following scenario in which we
21
+ # want to highlight the middle comma
22
+ # * node.markup == "replace ',',', '"
23
+ # * markup == ","
24
+ # Then we need some way of telling our Position class to start
25
+ # looking for the second comma. This is done with node_markup_offset.
26
+ # More context can be found in #376.
27
+ node_markup_offset: 0,
28
+ correction: nil # block
29
+ )
9
30
  @check = check
10
31
  @correction = correction
11
32
 
@@ -39,7 +60,13 @@ module ThemeCheck
39
60
  @node.line_number
40
61
  end
41
62
 
42
- @position = Position.new(@markup, @template&.source, @line_number)
63
+ @position = Position.new(
64
+ @markup,
65
+ @template&.source,
66
+ line_number_1_indexed: @line_number,
67
+ node_markup_offset: node_markup_offset,
68
+ node_markup: node&.markup
69
+ )
43
70
  end
44
71
 
45
72
  def source_excerpt
@@ -103,8 +130,13 @@ module ThemeCheck
103
130
  tokens.join(":") if tokens.any?
104
131
  end
105
132
 
133
+ def location_range
134
+ tokens = [template&.relative_path, start_index, end_index].compact
135
+ tokens.join(":") if tokens.any?
136
+ end
137
+
106
138
  def correctable?
107
- line_number && correction
139
+ !!correction
108
140
  end
109
141
 
110
142
  def correct
@@ -139,5 +171,26 @@ module ThemeCheck
139
171
  message
140
172
  end
141
173
  end
174
+
175
+ def to_s_range
176
+ if template
177
+ "#{message} at #{location_range}"
178
+ else
179
+ message
180
+ end
181
+ end
182
+
183
+ def to_h
184
+ {
185
+ check: check.code_name,
186
+ path: template&.relative_path,
187
+ severity: check.severity_value,
188
+ start_line: start_line,
189
+ start_column: start_column,
190
+ end_line: end_line,
191
+ end_column: end_column,
192
+ message: message,
193
+ }
194
+ end
142
195
  end
143
196
  end
@@ -5,13 +5,16 @@ module ThemeCheck
5
5
  def outside_of_strings(markup)
6
6
  scanner = StringScanner.new(markup)
7
7
 
8
- while scanner.scan(/.*?("|')/)
9
- yield scanner.matched[0..-2]
8
+ while scanner.scan(/.*?("|')/m)
9
+ chunk_start = scanner.pre_match.size
10
+ yield scanner.matched[0..-2], chunk_start
11
+ quote = scanner.matched[-1] == "'" ? "'" : "\""
10
12
  # Skip to the end of the string
11
- scanner.skip_until(scanner.matched[-1] == "'" ? /[^\\]'/ : /[^\\]"/)
13
+ # Check for empty string first, since follow regexp uses lookahead
14
+ scanner.skip(/#{quote}/) || scanner.skip_until(/[^\\]#{quote}/)
12
15
  end
13
16
 
14
- yield scanner.rest if scanner.rest?
17
+ yield scanner.rest, scanner.charpos if scanner.rest?
15
18
  end
16
19
  end
17
20
  end
@@ -4,42 +4,64 @@ module ThemeCheck
4
4
  class Position
5
5
  include PositionHelper
6
6
 
7
- def initialize(needle, contents, line_number_1_indexed)
8
- @needle = needle
9
- @contents = contents
7
+ def initialize(
8
+ needle_arg,
9
+ contents_arg,
10
+ line_number_1_indexed: nil,
11
+ node_markup: nil,
12
+ node_markup_offset: 0 # the index of markup inside the node_markup
13
+ )
14
+ @needle = needle_arg
15
+ @contents = contents_arg
10
16
  @line_number_1_indexed = line_number_1_indexed
11
- @start_row_column = nil
12
- @end_row_column = nil
17
+ @node_markup_offset = node_markup_offset
18
+ @node_markup = node_markup
19
+ @strict_position = StrictPosition.new(
20
+ needle,
21
+ contents,
22
+ start_index,
23
+ )
13
24
  end
14
25
 
15
- def start_line_index
26
+ def start_line_offset
16
27
  from_row_column_to_index(contents, line_number, 0)
17
28
  end
18
29
 
30
+ def start_offset
31
+ return start_line_offset if @node_markup.nil?
32
+ node_markup_start = contents.index(@node_markup, start_line_offset)
33
+ return start_line_offset if node_markup_start.nil?
34
+ node_markup_start + @node_markup_offset
35
+ end
36
+
19
37
  # 0-indexed, inclusive
20
38
  def start_index
21
- contents.index(needle, start_line_index) || start_line_index
39
+ contents.index(needle, start_offset)
22
40
  end
23
41
 
24
42
  # 0-indexed, exclusive
25
43
  def end_index
26
- start_index + needle.size
44
+ @strict_position.end_index
27
45
  end
28
46
 
47
+ # 0-indexed, inclusive
29
48
  def start_row
30
- start_row_column[0]
49
+ @strict_position.start_row
31
50
  end
32
51
 
52
+ # 0-indexed, inclusive
33
53
  def start_column
34
- start_row_column[1]
54
+ @strict_position.start_column
35
55
  end
36
56
 
57
+ # 0-indexed, exclusive (both taken together are) therefore you
58
+ # might end up on a newline character or the next line
37
59
  def end_row
38
- end_row_column[0]
60
+ @strict_position.end_row
39
61
  end
40
62
 
41
63
  def end_column
42
- end_row_column[1]
64
+ @strict_position.end_column
43
65
  end
44
66
 
45
67
  private
@@ -55,15 +77,77 @@ module ThemeCheck
55
77
  end
56
78
 
57
79
  def needle
58
- if @needle.nil? && !contents.empty? && !@line_number_1_indexed.nil?
59
- contents.lines(chomp: true)[line_number] || ''
80
+ if has_content_and_line_number_but_no_needle?
81
+ entire_line_needle
60
82
  elsif contents.empty? || @needle.nil?
61
83
  ''
84
+ elsif !can_find_needle?
85
+ entire_line_needle
62
86
  else
63
87
  @needle
64
88
  end
65
89
  end
66
90
 
91
+ def has_content_and_line_number_but_no_needle?
92
+ @needle.nil? && !contents.empty? && @line_number_1_indexed.is_a?(Integer)
93
+ end
94
+
95
+ def can_find_needle?
96
+ !!contents.index(@needle)
97
+ end
98
+
99
+ def entire_line_needle
100
+ contents.lines(chomp: true)[line_number] || ''
101
+ end
102
+ end
103
+
104
+ # This method is stricter than Position in the sense that it doesn't
105
+ # accept invalid inputs. Makes for code that is easier to understand.
106
+ class StrictPosition
107
+ include PositionHelper
108
+
109
+ attr_reader :needle, :contents
110
+
111
+ def initialize(needle, contents, start_index)
112
+ raise ArgumentError, 'Bad start_index' unless start_index.is_a?(Integer)
113
+ raise ArgumentError, 'Bad contents' unless contents.is_a?(String)
114
+ raise ArgumentError, 'Bad needle' unless needle.is_a?(String) || !contents.index(needle, start_index)
115
+
116
+ @needle = needle
117
+ @contents = contents
118
+ @start_index = start_index
119
+ @start_row_column = nil
120
+ @end_row_column = nil
121
+ end
122
+
123
+ # 0-indexed, inclusive
124
+ def start_index
125
+ @contents.index(needle, @start_index)
126
+ end
127
+
128
+ # 0-indexed, exclusive
129
+ def end_index
130
+ start_index + needle.size
131
+ end
132
+
133
+ def start_row
134
+ start_row_column[0]
135
+ end
136
+
137
+ def start_column
138
+ start_row_column[1]
139
+ end
140
+
141
+ def end_row
142
+ end_row_column[0]
143
+ end
144
+
145
+ def end_column
146
+ end_row_column[1]
147
+ end
148
+
149
+ private
150
+
67
151
  def start_row_column
68
152
  return @start_row_column unless @start_row_column.nil?
69
153
  @start_row_column = from_index_to_row_column(contents, start_index)