collie 0.1.0 → 1.0.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +55 -258
  4. data/lib/collie/analyzer/reachability.rb +17 -20
  5. data/lib/collie/analyzer/recursion.rb +28 -9
  6. data/lib/collie/analyzer/symbol_resolver.rb +51 -0
  7. data/lib/collie/ast.rb +18 -4
  8. data/lib/collie/cli.rb +388 -50
  9. data/lib/collie/config/schema.rb +117 -0
  10. data/lib/collie/config.rb +106 -22
  11. data/lib/collie/formatter/formatter.rb +95 -50
  12. data/lib/collie/formatter/options.rb +17 -5
  13. data/lib/collie/formatter/signature.rb +72 -0
  14. data/lib/collie/linter/base.rb +49 -0
  15. data/lib/collie/linter/rules/ambiguous_precedence.rb +5 -2
  16. data/lib/collie/linter/rules/circular_reference.rb +96 -38
  17. data/lib/collie/linter/rules/consistent_tag_naming.rb +13 -13
  18. data/lib/collie/linter/rules/empty_action.rb +42 -11
  19. data/lib/collie/linter/rules/factorizable_rules.rb +2 -2
  20. data/lib/collie/linter/rules/left_recursion.rb +5 -4
  21. data/lib/collie/linter/rules/long_rule.rb +3 -3
  22. data/lib/collie/linter/rules/nonterminal_naming.rb +6 -4
  23. data/lib/collie/linter/rules/prec_improvement.rb +1 -1
  24. data/lib/collie/linter/rules/redundant_epsilon.rb +11 -11
  25. data/lib/collie/linter/rules/right_recursion.rb +4 -1
  26. data/lib/collie/linter/rules/symbol_conflict.rb +130 -0
  27. data/lib/collie/linter/rules/token_naming.rb +2 -1
  28. data/lib/collie/linter/rules/trailing_whitespace.rb +7 -1
  29. data/lib/collie/linter/rules/undefined_symbol.rb +50 -8
  30. data/lib/collie/linter/rules/unused_nonterminal.rb +36 -1
  31. data/lib/collie/linter/rules/unused_token.rb +34 -9
  32. data/lib/collie/parser/debug_serializer.rb +205 -0
  33. data/lib/collie/parser/lexer.rb +182 -11
  34. data/lib/collie/parser/parser.rb +73 -13
  35. data/lib/collie/reporter/github.rb +15 -2
  36. data/lib/collie/reporter/json.rb +4 -1
  37. data/lib/collie/reporter/sarif.rb +81 -0
  38. data/lib/collie/version.rb +1 -1
  39. data/lib/collie.rb +6 -1
  40. metadata +8 -2
data/lib/collie/cli.rb CHANGED
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require "json"
5
+ require_relative "config"
6
+ require_relative "formatter/signature"
7
+ require_relative "parser/debug_serializer"
4
8
 
5
9
  module Collie
6
10
  # Command-line interface
7
11
  class CLI < Thor
12
+ PARSE_ERROR_RULE = Class.new do
13
+ class << self
14
+ def rule_name = "ParseError"
15
+ def description = "Reports grammar parse errors"
16
+ def severity = :error
17
+ def autocorrectable = false
18
+ end
19
+ end
20
+
8
21
  def self.exit_on_failure?
9
22
  true
10
23
  end
@@ -13,24 +26,34 @@ module Collie
13
26
 
14
27
  desc "lint FILES", "Lint grammar files"
15
28
  option :config, type: :string, desc: "Config file path"
16
- option :format, type: :string, default: "text", enum: %w[text json github], desc: "Output format"
29
+ option :format, type: :string, default: "text", enum: %w[text json github sarif], desc: "Output format"
17
30
  option :autocorrect, type: :boolean, aliases: "-a", desc: "Auto-fix offenses"
18
- option :only, type: :array, desc: "Run only specified rules"
19
- option :except, type: :array, desc: "Exclude specified rules"
31
+ option :only, type: :string, repeatable: true, desc: "Run only specified rules"
32
+ option :except, type: :string, repeatable: true, desc: "Exclude specified rules"
33
+ option :fail_level, type: :string, default: "error", enum: %w[error warning convention info],
34
+ desc: "Minimum severity that exits with failure"
35
+ option :stdin, type: :boolean, desc: "Read source from standard input"
36
+ option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
20
37
  def lint(*files)
38
+ config = load_config
39
+ Linter::Registry.load_rules
40
+ validate_rule_filters!
41
+
42
+ return lint_stdin(config) if options[:stdin]
43
+
44
+ files = resolve_files(files, config)
21
45
  if files.empty?
22
- say "No files specified", :red
46
+ say "No files matched", :red
23
47
  exit 1
24
48
  end
25
49
 
26
- config = Config.new(options[:config])
27
- Linter::Registry.load_rules
28
-
29
50
  all_offenses = []
51
+ failed = false
30
52
 
31
53
  files.each do |file|
32
54
  unless File.exist?(file)
33
55
  say "File not found: #{file}", :red
56
+ failed = true
34
57
  next
35
58
  end
36
59
 
@@ -41,64 +64,146 @@ module Collie
41
64
  reporter = create_reporter(options[:format])
42
65
  puts reporter.report(all_offenses)
43
66
 
44
- exit 1 if all_offenses.any? { |o| o.severity == :error }
67
+ exit 1 if failed || fail_level_reached?(all_offenses)
45
68
  end
46
69
 
47
70
  desc "fmt FILES", "Format grammar files"
48
71
  option :check, type: :boolean, desc: "Check only, don't modify"
49
72
  option :diff, type: :boolean, desc: "Show diff"
50
73
  option :config, type: :string, desc: "Config file path"
74
+ option :stdin, type: :boolean, desc: "Read source from standard input"
75
+ option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
51
76
  def fmt(*files)
77
+ config = load_config
78
+ formatter = Formatter::Formatter.new(Formatter::Options.new(config.formatter_options))
79
+
80
+ return fmt_stdin(formatter) if options[:stdin]
81
+
82
+ files = resolve_files(files, config)
52
83
  if files.empty?
53
- say "No files specified", :red
84
+ say "No files matched", :red
54
85
  exit 1
55
86
  end
56
87
 
57
- config = Config.new(options[:config])
58
- formatter = Formatter::Formatter.new(Formatter::Options.new(config.formatter_options))
88
+ failed = false
89
+ changed = false
59
90
 
60
91
  files.each do |file|
61
92
  unless File.exist?(file)
62
93
  say "File not found: #{file}", :red
94
+ failed = true
63
95
  next
64
96
  end
65
97
 
66
- format_file(file, formatter, check: options[:check], diff: options[:diff])
98
+ result = format_file(file, formatter, check: options[:check], diff: options[:diff])
99
+ failed = true if result == :failed
100
+ changed = true if result == :changed
67
101
  end
102
+
103
+ exit 1 if failed || changed
68
104
  end
69
105
 
70
106
  desc "rules", "List all available rules"
107
+ option :config, type: :string, desc: "Config file path"
71
108
  option :format, type: :string, default: "text", enum: %w[text json]
72
109
  def rules
110
+ config = load_config
73
111
  Linter::Registry.load_rules
74
112
 
75
113
  if options[:format] == "json"
76
114
  output = Linter::Registry.all.map do |rule|
77
- {
78
- name: rule.rule_name,
79
- description: rule.description,
80
- severity: rule.severity,
81
- autocorrectable: rule.autocorrectable
82
- }
115
+ rule_config = config.rule_config(rule.rule_name)
116
+ rule_metadata(rule, config, rule_config)
83
117
  end
84
118
  puts JSON.pretty_generate(output)
85
119
  else
86
120
  say "Available lint rules:", :bold
87
121
  Linter::Registry.all.each do |rule|
88
- severity_color = severity_color(rule.severity)
122
+ rule_config = config.rule_config(rule.rule_name)
123
+ severity = configured_rule_severity(rule, rule_config)
124
+ severity_color = severity_color(severity)
89
125
  autocorrect = rule.autocorrectable ? " [autocorrectable]" : ""
90
- say " #{rule.rule_name} (#{set_color(rule.severity, severity_color)})#{autocorrect}"
126
+ enabled = config.rule_enabled?(rule.rule_name) ? "" : " [disabled]"
127
+ say " #{rule.rule_name} (#{set_color(severity, severity_color)})#{autocorrect}#{enabled}"
91
128
  say " #{rule.description}", :dim
92
129
  end
93
130
  end
94
131
  end
95
132
 
96
- desc "init", "Generate default .collie.yml"
133
+ desc "explain RULE", "Explain a lint rule"
134
+ option :config, type: :string, desc: "Config file path"
135
+ option :format, type: :string, default: "text", enum: %w[text json]
136
+ def explain(rule_name)
137
+ config = load_config
138
+ Linter::Registry.load_rules
139
+
140
+ rule = Linter::Registry.find(rule_name)
141
+ unless rule
142
+ say "Unknown rule: #{rule_name}", :red
143
+ exit 1
144
+ end
145
+
146
+ rule_config = config.rule_config(rule.rule_name)
147
+ metadata = rule_metadata(rule, config, rule_config)
148
+
149
+ if options[:format] == "json"
150
+ puts JSON.pretty_generate(metadata)
151
+ else
152
+ say metadata[:name], :bold
153
+ say " Description: #{metadata[:description]}"
154
+ say " Enabled: #{metadata[:enabled]}"
155
+ say " Severity: #{metadata[:severity]}"
156
+ say " Autocorrectable: #{metadata[:autocorrectable]}"
157
+ end
158
+ end
159
+
160
+ desc "tokens FILE", "Print lexer tokens for debugging"
161
+ option :stdin, type: :boolean, desc: "Read source from standard input"
162
+ option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
163
+ def tokens(file = nil)
164
+ source, filename = debug_source(file)
165
+ lexer = Parser::Lexer.new(source, filename: filename)
166
+ output = lexer.tokenize.map { |token| Parser::DebugSerializer.token(token) }
167
+
168
+ puts JSON.pretty_generate(output)
169
+ rescue Error => e
170
+ say e.message, :red
171
+ exit 1
172
+ end
173
+
174
+ desc "ast FILE", "Print parsed AST for debugging"
175
+ option :stdin, type: :boolean, desc: "Read source from standard input"
176
+ option :stdin_filename, type: :string, default: "<stdin>", desc: "Filename to use for standard input"
177
+ def ast(file = nil)
178
+ source, filename = debug_source(file)
179
+ tree = parse_source(source, filename: filename)
180
+
181
+ puts JSON.pretty_generate(Parser::DebugSerializer.ast(tree))
182
+ rescue Error => e
183
+ say e.message, :red
184
+ exit 1
185
+ end
186
+
187
+ map "config-schema" => :config_schema
188
+
189
+ desc "init", "Generate .collie.yml"
190
+ option :profile, type: :string, default: "default", enum: Config::PROFILE_NAMES,
191
+ desc: "Configuration profile"
192
+ option :path, type: :string, default: ".collie.yml", desc: "Path to write"
97
193
  def init
98
- return if File.exist?(".collie.yml") && !yes?(".collie.yml already exists. Overwrite? (y/n)")
194
+ path = options[:path]
195
+ return if File.exist?(path) && !yes?("#{path} already exists. Overwrite? (y/n)")
196
+
197
+ Config.generate_default(path, profile: options[:profile])
198
+ say "Generated #{path}", :green
199
+ rescue Error => e
200
+ say e.message, :red
201
+ exit 1
202
+ end
99
203
 
100
- Config.generate_default
101
- say "Generated .collie.yml", :green
204
+ desc "config-schema", "Print JSON Schema for .collie.yml"
205
+ def config_schema
206
+ puts JSON.pretty_generate(Config.schema)
102
207
  end
103
208
 
104
209
  desc "version", "Show version"
@@ -108,23 +213,41 @@ module Collie
108
213
 
109
214
  private
110
215
 
216
+ def load_config
217
+ Config.new(options[:config])
218
+ rescue Error => e
219
+ say e.message, :red
220
+ exit 1
221
+ end
222
+
223
+ def lint_stdin(config)
224
+ source = $stdin.read
225
+ offenses = lint_source(source, filename: options[:stdin_filename], config: config)
226
+
227
+ reporter = create_reporter(options[:format])
228
+ puts reporter.report(offenses)
229
+
230
+ exit 1 if fail_level_reached?(offenses)
231
+ end
232
+
111
233
  def lint_file(file, config)
112
234
  source = File.read(file)
113
- lexer = Parser::Lexer.new(source, filename: file)
114
- tokens = lexer.tokenize
115
- parser = Parser::Parser.new(tokens)
116
- ast = parser.parse
235
+ lint_source(source, filename: file, config: config, autocorrect_path: file)
236
+ end
237
+
238
+ def lint_source(source, filename:, config:, autocorrect_path: nil)
239
+ ast = parse_source(source, filename: filename)
117
240
 
118
241
  symbol_table = build_symbol_table(ast)
119
- context = { symbol_table: symbol_table, source: source, file: file }
242
+ Analyzer::SymbolResolver.resolve(ast, symbol_table)
243
+ context = { symbol_table: symbol_table, source: source, file: filename }
120
244
 
121
245
  offenses = run_lint_rules(ast, context, config)
122
- apply_autocorrect(file, source, context, offenses) if options[:autocorrect]
246
+ apply_autocorrect(autocorrect_path, source, context, offenses) if autocorrect_path && options[:autocorrect]
123
247
 
124
248
  offenses
125
249
  rescue Error => e
126
- say "Error parsing #{file}: #{e.message}", :red
127
- []
250
+ [parse_error_offense(filename, e.message)]
128
251
  end
129
252
 
130
253
  def build_symbol_table(ast)
@@ -135,10 +258,18 @@ module Collie
135
258
  decl.names.each do |name|
136
259
  symbol_table.add_token(name, type_tag: decl.type_tag, location: decl.location)
137
260
  rescue Error
138
- # Ignore duplicate declarations here, they'll be caught by lint rules
261
+ # Ignore duplicates while building the resolver table.
262
+ end
263
+ when AST::PrecedenceDeclaration
264
+ decl.tokens.each do |name|
265
+ symbol_table.add_token(name, location: decl.location)
266
+ rescue Error
267
+ # Ignore duplicates while building the resolver table.
139
268
  end
140
269
  when AST::ParameterizedRule
141
270
  symbol_table.add_nonterminal(decl.name, location: decl.location)
271
+ when AST::InlineRule
272
+ symbol_table.add_nonterminal(decl.rule, location: decl.location)
142
273
  end
143
274
  end
144
275
 
@@ -176,51 +307,219 @@ module Collie
176
307
  say "Auto-corrected #{autocorrectable_offenses.size} offense(s) in #{file}", :green
177
308
  end
178
309
 
310
+ def fmt_stdin(formatter)
311
+ source = $stdin.read
312
+ formatted = format_source(source, formatter, filename: options[:stdin_filename])
313
+ exit 1 unless formatted
314
+
315
+ if options[:check]
316
+ if source == formatted
317
+ say "#{options[:stdin_filename]}: OK", :green
318
+ else
319
+ say "#{options[:stdin_filename]}: needs formatting", :yellow
320
+ show_diff(source, formatted) if options[:diff]
321
+ exit 1
322
+ end
323
+ elsif options[:diff]
324
+ if source == formatted
325
+ say "#{options[:stdin_filename]}: OK", :green
326
+ else
327
+ say "#{options[:stdin_filename]}: needs formatting", :yellow
328
+ show_diff(source, formatted)
329
+ exit 1
330
+ end
331
+ else
332
+ puts formatted
333
+ end
334
+ end
335
+
179
336
  def format_file(file, formatter, check: false, diff: false)
180
337
  source = File.read(file)
181
- lexer = Parser::Lexer.new(source, filename: file)
182
- tokens = lexer.tokenize
183
- parser = Parser::Parser.new(tokens)
184
- ast = parser.parse
185
-
186
- formatted = formatter.format(ast)
338
+ formatted = format_source(source, formatter, filename: file)
339
+ return :failed unless formatted
187
340
 
188
341
  if check
189
342
  if source == formatted
190
343
  say "#{file}: OK", :green
344
+ :ok
191
345
  else
192
346
  say "#{file}: needs formatting", :yellow
193
347
  show_diff(source, formatted) if diff
348
+ :changed
349
+ end
350
+ elsif diff
351
+ if source == formatted
352
+ say "#{file}: OK", :green
353
+ :ok
354
+ else
355
+ say "#{file}: needs formatting", :yellow
356
+ show_diff(source, formatted)
357
+ :changed
194
358
  end
195
359
  else
196
360
  File.write(file, formatted)
197
361
  say "Formatted #{file}", :green
362
+ :ok
198
363
  end
199
364
  rescue Error => e
200
365
  say "Error formatting #{file}: #{e.message}", :red
366
+ :failed
367
+ end
368
+
369
+ def format_source(source, formatter, filename:)
370
+ ast = parse_source(source, filename: filename)
371
+ formatted = formatter.format(ast)
372
+ formatted_ast = parse_source(formatted, filename: filename)
373
+ unless Formatter::Signature.build(ast) == Formatter::Signature.build(formatted_ast)
374
+ raise Error, "Formatted output changed grammar structure"
375
+ end
376
+
377
+ formatted
378
+ rescue Error => e
379
+ say "Error formatting #{filename}: #{e.message}", :red
380
+ nil
381
+ end
382
+
383
+ def parse_source(source, filename:)
384
+ lexer = Parser::Lexer.new(source, filename: filename)
385
+ tokens = lexer.tokenize
386
+ parser = Parser::Parser.new(tokens)
387
+ parser.parse
388
+ end
389
+
390
+ def debug_source(file)
391
+ return [$stdin.read, options[:stdin_filename]] if options[:stdin]
392
+
393
+ unless file
394
+ say "No file specified", :red
395
+ exit 1
396
+ end
397
+
398
+ unless File.exist?(file)
399
+ say "File not found: #{file}", :red
400
+ exit 1
401
+ end
402
+
403
+ [File.read(file), file]
404
+ end
405
+
406
+ def parse_error_offense(file, message)
407
+ Linter::Offense.new(
408
+ rule: PARSE_ERROR_RULE,
409
+ location: parse_error_location(file, message),
410
+ message: message,
411
+ severity: :error
412
+ )
413
+ end
414
+
415
+ def parse_error_location(file, message)
416
+ match = message.match(/:(\d+):(\d+)\b/)
417
+ line = match ? match[1].to_i : 1
418
+ column = match ? match[2].to_i : 1
419
+
420
+ AST::Location.new(file: file, line: line, column: column)
201
421
  end
202
422
 
203
423
  def filter_rules(rules)
204
424
  filtered = rules
205
425
 
206
- filtered = filtered.select { |r| options[:only].include?(r.rule_name) } if options[:only]
426
+ only = rule_filter(:only)
427
+ except = rule_filter(:except)
428
+
429
+ filtered = filtered.select { |r| only.include?(r.rule_name) } if only.any?
207
430
 
208
- filtered = filtered.reject { |r| options[:except].include?(r.rule_name) } if options[:except]
431
+ filtered = filtered.reject { |r| except.include?(r.rule_name) } if except.any?
209
432
 
210
433
  filtered
211
434
  end
212
435
 
436
+ def rule_filter(option_name)
437
+ Array(options[option_name]).flat_map { |value| value.split(",") }.map(&:strip).reject(&:empty?)
438
+ end
439
+
440
+ def validate_rule_filters!
441
+ unknown_rules = (rule_filter(:only) + rule_filter(:except)).uniq.reject do |rule_name|
442
+ Linter::Registry.find(rule_name)
443
+ end
444
+ return if unknown_rules.empty?
445
+
446
+ say "Unknown rule(s): #{unknown_rules.join(', ')}", :red
447
+ exit 1
448
+ end
449
+
450
+ def resolve_files(files, config)
451
+ targets = files.empty? ? config.included_patterns : files
452
+ resolved = targets.flat_map { |target| expand_target(target) }.uniq
453
+ return resolved unless files.empty?
454
+
455
+ resolved.reject { |file| excluded?(file, config.excluded_patterns) }
456
+ end
457
+
458
+ def expand_target(target)
459
+ if glob_pattern?(target)
460
+ return Dir.glob(target).select { |path| File.file?(path) }.sort
461
+ end
462
+
463
+ if File.directory?(target)
464
+ return Dir.glob(File.join(target, "**", "*.y")).select { |path| File.file?(path) }.sort
465
+ end
466
+
467
+ [target]
468
+ end
469
+
470
+ def glob_pattern?(target)
471
+ target.match?(/[*?\[\]{}]/)
472
+ end
473
+
474
+ def excluded?(file, patterns)
475
+ patterns.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
476
+ end
477
+
478
+ def fail_level_reached?(offenses)
479
+ threshold = severity_rank(options[:fail_level].to_sym)
480
+ offenses.any? { |offense| severity_rank(offense.severity) >= threshold }
481
+ end
482
+
483
+ def severity_rank(severity)
484
+ {
485
+ info: 0,
486
+ convention: 1,
487
+ warning: 2,
488
+ error: 3
489
+ }.fetch(severity, 3)
490
+ end
491
+
213
492
  def create_reporter(format)
214
493
  case format
215
494
  when "json"
216
495
  Reporter::Json.new
217
496
  when "github"
218
497
  Reporter::Github.new
498
+ when "sarif"
499
+ Reporter::Sarif.new
219
500
  else
220
501
  Reporter::Text.new
221
502
  end
222
503
  end
223
504
 
505
+ def configured_rule_severity(rule, rule_config)
506
+ configured = rule_config["severity"] || rule_config[:severity]
507
+ return rule.severity unless configured
508
+
509
+ normalized = configured.to_sym
510
+ Linter::Base::VALID_SEVERITIES.include?(normalized) ? normalized : rule.severity
511
+ end
512
+
513
+ def rule_metadata(rule, config, rule_config)
514
+ {
515
+ name: rule.rule_name,
516
+ description: rule.description,
517
+ enabled: config.rule_enabled?(rule.rule_name),
518
+ severity: configured_rule_severity(rule, rule_config),
519
+ autocorrectable: rule.autocorrectable
520
+ }
521
+ end
522
+
224
523
  def severity_color(severity)
225
524
  case severity
226
525
  when :error then :red
@@ -232,18 +531,57 @@ module Collie
232
531
  end
233
532
 
234
533
  def show_diff(original, formatted)
235
- require "tempfile"
534
+ puts unified_diff(original, formatted)
535
+ end
236
536
 
237
- Tempfile.create(["original", ".y"]) do |orig|
238
- Tempfile.create(["formatted", ".y"]) do |fmt|
239
- orig.write(original)
240
- orig.flush
241
- fmt.write(formatted)
242
- fmt.flush
537
+ def unified_diff(original, formatted)
538
+ original_lines = original.lines
539
+ formatted_lines = formatted.lines
540
+ prefix = common_prefix_length(original_lines, formatted_lines)
541
+ suffix = common_suffix_length(original_lines, formatted_lines, prefix)
542
+
543
+ original_start = [prefix - 3, 0].max
544
+ formatted_start = [prefix - 3, 0].max
545
+ original_end = [original_lines.length - suffix + 3, original_lines.length].min
546
+ formatted_end = [formatted_lines.length - suffix + 3, formatted_lines.length].min
547
+
548
+ output = [
549
+ "--- original",
550
+ "+++ formatted",
551
+ "@@ -#{hunk_range(original_start, original_end)} +#{hunk_range(formatted_start, formatted_end)} @@"
552
+ ]
553
+
554
+ original_lines[original_start...prefix].each { |line| output << " #{line.chomp}" }
555
+ original_lines[prefix...(original_lines.length - suffix)].each { |line| output << "-#{line.chomp}" }
556
+ formatted_lines[prefix...(formatted_lines.length - suffix)].each { |line| output << "+#{line.chomp}" }
557
+ formatted_lines[(formatted_lines.length - suffix)...formatted_end].each { |line| output << " #{line.chomp}" }
558
+
559
+ output.join("\n")
560
+ end
243
561
 
244
- system("diff", "-u", orig.path, fmt.path)
245
- end
562
+ def common_prefix_length(left, right)
563
+ index = 0
564
+ index += 1 while index < left.length && index < right.length && left[index] == right[index]
565
+ index
566
+ end
567
+
568
+ def common_suffix_length(left, right, prefix_length)
569
+ left_index = left.length - 1
570
+ right_index = right.length - 1
571
+ count = 0
572
+
573
+ while left_index >= prefix_length && right_index >= prefix_length && left[left_index] == right[right_index]
574
+ count += 1
575
+ left_index -= 1
576
+ right_index -= 1
246
577
  end
578
+
579
+ count
580
+ end
581
+
582
+ def hunk_range(start_index, end_index)
583
+ length = end_index - start_index
584
+ length == 1 ? (start_index + 1).to_s : "#{start_index + 1},#{length}"
247
585
  end
248
586
  end
249
587
  end