yamlfmt 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 571a8a62760ac20bcdba1b1d26ff3b269bf4714dedb060d764e6b0dfacecf47f
4
+ data.tar.gz: 6d41e7d35bb249788d291d407ab2569201fc0cd96f6d5fa48fed84f7394a089d
5
+ SHA512:
6
+ metadata.gz: eda1c531fd15373645247284edcc73c62c0568c31a635bc91277a05797e2f137bbdbabd4a473ac98be85967a732937c42c29060c1e33c5dad87be1ab7adcb4e7
7
+ data.tar.gz: 7a2f78db1ceee85098fea0ca4bdb4e71877c89c5aaacfda43a58510282a5ffdd2c2c03b157dbd8c4ce6c012616cd500bcde0eabfa51bf22a3334fda1110f02c5
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.5
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryuki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # yamlfmt
2
+
3
+ `yamlfmt` is a comment-preserving, minimal-diff YAML formatter for Ruby. It
4
+ applies targeted edits to the original source instead of re-emitting the entire
5
+ document, preserving comments and untouched formatting.
6
+
7
+ It is built on [psych-pure](https://github.com/kddnewton/psych-pure).
8
+
9
+ This project is unrelated to the Go formatter with the same name.
10
+
11
+ ## Installation
12
+
13
+ Install yamlfmt from RubyGems:
14
+
15
+ ```console
16
+ $ gem install yamlfmt
17
+ ```
18
+
19
+ To use it in a Rails application, add it to the application's `Gemfile`:
20
+
21
+ ```ruby
22
+ group :development, :test do
23
+ gem "yamlfmt", require: false
24
+ end
25
+ ```
26
+
27
+ Then run `bundle install` and invoke it with `bundle exec yamlfmt`.
28
+
29
+ yamlfmt requires Ruby 3.3 or newer.
30
+
31
+ ## Usage
32
+
33
+ Running without an option checks YAML files below the current directory. It
34
+ reports formatting issues but never modifies a file.
35
+
36
+ ```console
37
+ $ yamlfmt
38
+ $ yamlfmt config/locales
39
+ $ yamlfmt config/application.yml
40
+ ```
41
+
42
+ Directories are searched recursively for `.yml` and `.yaml` files. A file
43
+ passed directly is checked regardless of its extension. Hidden directories,
44
+ including `.github`, are searched; `.git` and symbolic links are always
45
+ skipped.
46
+
47
+ Preview a unified diff before applying changes:
48
+
49
+ ```console
50
+ $ yamlfmt --diff
51
+ $ yamlfmt --fix
52
+ ```
53
+
54
+ `--fix` with no path rewrites every applicable YAML file below the current
55
+ directory. Running `--diff` first is strongly recommended. `--fix` and
56
+ `--diff` cannot be combined.
57
+
58
+ Create a starter configuration:
59
+
60
+ ```console
61
+ $ yamlfmt --init
62
+ ```
63
+
64
+ Other options are available through `yamlfmt --help`. Colors are used only when
65
+ writing a diff to a terminal and can be disabled with `--no-color`.
66
+
67
+ Check mode ends with the number of files inspected, issues found, and issues
68
+ that can be corrected automatically:
69
+
70
+ ```text
71
+ 12 files inspected, 5 issues found, 4 autocorrectable
72
+ ```
73
+
74
+ Fix mode instead reports how many issues and files were actually corrected,
75
+ followed by the number of issues that remain when applicable:
76
+
77
+ ```text
78
+ 12 files inspected, 5 issues found, 4 corrected in 2 files, 1 issue remains
79
+ ```
80
+
81
+ Warnings and failed files are reported separately from issue counts. Diff mode
82
+ does not print a summary, so its output can be redirected or piped as a patch.
83
+
84
+ ### Exit status
85
+
86
+ | Status | Meaning |
87
+ |---|---|
88
+ | 0 | No issues, no targets, or all requested fixes succeeded |
89
+ | 1 | Formatting issues in check/diff mode, invalid input, an unsupported file, or a failed fix |
90
+
91
+ When one file fails, yamlfmt continues processing the remaining files. In fix
92
+ mode, files that pass validation are still rewritten, and the final status is
93
+ 1 if any file failed.
94
+
95
+ ## Configuration
96
+
97
+ yamlfmt reads `.yamlfmt.yml` from the current directory only. It does not search
98
+ parent directories.
99
+
100
+ ```yaml
101
+ rules:
102
+ trailing-whitespace: true
103
+ final-newline: true
104
+ blank-lines:
105
+ max: 1
106
+ unnecessary-quotes: true
107
+
108
+ exclude:
109
+ - vendor
110
+ - node_modules
111
+ - "*.generated.yml"
112
+ - "tmp/**"
113
+ ```
114
+
115
+ Set a rule to `false` to disable it. A mapping overrides that rule's options.
116
+ Unknown rule and option names produce warnings; invalid values are errors.
117
+
118
+ Exclude patterns are relative to the current directory:
119
+
120
+ - A name without `/`, such as `node_modules`, matches a file or directory with
121
+ that name at any depth. Matching a directory excludes everything below it.
122
+ - A path such as `config/generated` matches that path and everything below it.
123
+ - `*` matches characters within one path component, `?` matches one character,
124
+ and a final `/**` matches everything below a directory.
125
+ - Gitignore-style negation with `!` is not supported.
126
+
127
+ Quote patterns beginning with `*` in YAML so they are not interpreted as YAML
128
+ aliases. Exclusions also apply to files passed explicitly.
129
+
130
+ yamlfmt does not read `.gitignore`. Configure exclusions in `.yamlfmt.yml`.
131
+
132
+ ## Rules
133
+
134
+ ### `trailing-whitespace`
135
+
136
+ Removes spaces and tabs at line endings.
137
+
138
+ ### `final-newline`
139
+
140
+ Adds a missing final newline and removes extra blank lines at the end of a
141
+ file. Existing LF or CRLF line endings are preserved.
142
+
143
+ ### `blank-lines`
144
+
145
+ Limits consecutive blank lines within a file. Whitespace-only lines count as
146
+ blank. The default maximum is one.
147
+
148
+ ### `unnecessary-quotes`
149
+
150
+ Removes single or double quotes only when Psych would emit the string in plain
151
+ style. Values such as `yes`, numbers, dates, `null`, interpolation placeholders,
152
+ and strings with YAML indicators remain quoted. Both YAML values and keys are
153
+ checked.
154
+
155
+ For example, only the safely unquotable value is changed:
156
+
157
+ ```diff
158
+ -foo: "hello"
159
+ +foo: hello
160
+ bar: "yes"
161
+ date: "2026-09-06"
162
+ ```
163
+
164
+ ## Safety and known limitations
165
+
166
+ Before a changed file is written, yamlfmt checks that:
167
+
168
+ - `Psych.safe_load_stream` produces an equivalent Ruby value;
169
+ - the number and logical content of comments are unchanged; and
170
+ - the corrected source can still be parsed as supported YAML.
171
+
172
+ The file is not written if validation fails.
173
+
174
+ yamlfmt skips line-based rules for an entire file when it contains a block
175
+ scalar (`|` or `>`). `unnecessary-quotes` still runs. This restriction avoids a
176
+ known psych-pure location bug; yamlfmt prints a warning when it applies.
177
+
178
+ The following inputs are currently unsupported and cause exit status 1 without
179
+ modifying that file:
180
+
181
+ - multiple YAML documents;
182
+ - a UTF-8 byte order mark;
183
+ - custom YAML tags; and
184
+ - anchors attached directly to scalar values.
185
+
186
+ ## Development
187
+
188
+ After checking out the repository, install the selected Ruby and dependencies:
189
+
190
+ ```console
191
+ $ rbenv install --skip-existing 3.3.5
192
+ $ bundle install
193
+ ```
194
+
195
+ Run the tests and Standard Ruby checks together:
196
+
197
+ ```console
198
+ $ bundle exec rake
199
+ ```
200
+
201
+ Build the gem with `bundle exec rake build` or install it locally with
202
+ `bundle exec rake install`.
203
+
204
+ ## Contributing
205
+
206
+ Bug reports and pull requests are welcome on
207
+ [GitHub](https://github.com/zeronosu77108/yamlfmt).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
data/exe/yamlfmt ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "yamlfmt"
4
+
5
+ exit Yamlfmt::CLI.start(ARGV)
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "pathname"
5
+
6
+ module Yamlfmt
7
+ class CLI
8
+ INIT_CONFIG = <<~YAML
9
+ # .yamlfmt.yml
10
+ rules:
11
+ trailing-whitespace: true
12
+ final-newline: true
13
+ blank-lines:
14
+ max: 1
15
+ unnecessary-quotes: true
16
+
17
+ exclude:
18
+ - vendor
19
+ - node_modules
20
+ YAML
21
+
22
+ def self.start(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
23
+ new(stdout:, stderr:, cwd:).run(argv)
24
+ end
25
+
26
+ def initialize(stdout:, stderr:, cwd:, processor: Processor.new, diff: UnifiedDiff.new)
27
+ @stdout = stdout
28
+ @stderr = stderr
29
+ @cwd = File.expand_path(cwd)
30
+ @processor = processor
31
+ @diff = diff
32
+ end
33
+
34
+ def run(argv)
35
+ options, paths, parser = parse(argv)
36
+ return print_help(parser) if options[:help]
37
+ return print_version if options[:version]
38
+ return initialize_config(options, paths) if options[:init]
39
+
40
+ validate_modes!(options)
41
+ process_files(options, paths)
42
+ rescue OptionParser::ParseError, ConfigError, PathError => error
43
+ @stderr.puts("yamlfmt: #{error.message}")
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ def parse(argv)
50
+ options = {fix: false, diff: false, init: false, color: true}
51
+ parser = OptionParser.new do |opts|
52
+ opts.banner = "Usage: yamlfmt [options] [paths...]"
53
+ opts.on("--fix", "Rewrite files in place") { options[:fix] = true }
54
+ opts.on("--diff", "Print a unified diff without rewriting files") { options[:diff] = true }
55
+ opts.on("--init", "Create .yamlfmt.yml in the current directory") { options[:init] = true }
56
+ opts.on("--no-color", "Disable colored output") { options[:color] = false }
57
+ opts.on("--version", "Print the version") { options[:version] = true }
58
+ opts.on("-h", "--help", "Print this help") { options[:help] = true }
59
+ end
60
+ paths = parser.permute!(argv.dup)
61
+ [options, paths, parser]
62
+ end
63
+
64
+ def validate_modes!(options)
65
+ raise OptionParser::InvalidOption, "--fix and --diff cannot be used together" if options[:fix] && options[:diff]
66
+ end
67
+
68
+ def initialize_config(options, paths)
69
+ if options[:fix] || options[:diff] || !paths.empty?
70
+ raise OptionParser::InvalidOption, "--init cannot be combined with formatting options or paths"
71
+ end
72
+
73
+ path = File.join(@cwd, Config::FILE_NAME)
74
+ raise ConfigError, "#{Config::FILE_NAME} already exists" if File.exist?(path)
75
+
76
+ File.write(path, INIT_CONFIG)
77
+ @stdout.puts("Created #{Config::FILE_NAME}")
78
+ 0
79
+ end
80
+
81
+ def process_files(options, paths)
82
+ config = Config.load(cwd: @cwd)
83
+ config.warnings.each { |warning| warn_message(warning) }
84
+ rules = RulePlan.new.call(config)
85
+ files = FileFinder.new(cwd: @cwd, exclude: config.exclude).call(paths)
86
+ state = {
87
+ error: false,
88
+ issues: 0,
89
+ autocorrectable: 0,
90
+ corrected: 0,
91
+ corrected_files: 0,
92
+ remaining: 0,
93
+ failed_files: 0
94
+ }
95
+
96
+ files.each do |path|
97
+ process_file(path, rules, options, state)
98
+ end
99
+
100
+ print_summary(files.length, state, fix: options[:fix]) unless options[:diff]
101
+
102
+ return 1 if state[:error] || state[:remaining].positive?
103
+ return 0 if options[:fix]
104
+
105
+ state[:issues].positive? ? 1 : 0
106
+ end
107
+
108
+ def process_file(path, rules, options, state)
109
+ autocorrectable = 0
110
+ source = File.binread(path).force_encoding(Encoding::UTF_8)
111
+ result = @processor.call(source, path:, rules:)
112
+ display_path = display_path(path)
113
+ result.warnings.each { |warning| warn_message("#{display_path}: #{warning}") }
114
+ autocorrectable = result.findings.count(&:autocorrectable?)
115
+ state[:issues] += result.findings.length
116
+ state[:autocorrectable] += autocorrectable
117
+
118
+ if options[:diff]
119
+ print_diff(result, display_path, options)
120
+ print_findings(result, display_path, findings: result.findings.reject(&:autocorrectable?))
121
+ else
122
+ print_findings(result, display_path)
123
+ end
124
+
125
+ if options[:fix]
126
+ state[:remaining] += result.findings.length - autocorrectable
127
+
128
+ if result.changed?
129
+ File.binwrite(path, result.formatted_source)
130
+ state[:corrected] += autocorrectable
131
+ state[:corrected_files] += 1
132
+ else
133
+ state[:remaining] += autocorrectable
134
+ end
135
+ end
136
+ rescue Error, SystemCallError => error
137
+ @stderr.puts("#{display_path(path)}: #{error.message}")
138
+ state[:error] = true
139
+ state[:remaining] += autocorrectable if options[:fix]
140
+ state[:failed_files] += 1
141
+ end
142
+
143
+ def print_summary(file_count, state, fix:)
144
+ @stdout.puts if state[:issues].positive?
145
+
146
+ parts = ["#{count(file_count, "file")} inspected"]
147
+ if state[:issues].zero?
148
+ parts << "no issues found"
149
+ else
150
+ parts << "#{count(state[:issues], "issue")} found"
151
+ if fix
152
+ parts << corrected_summary(state)
153
+ parts << remaining_summary(state[:remaining]) if state[:remaining].positive?
154
+ else
155
+ parts << "#{state[:autocorrectable]} autocorrectable"
156
+ end
157
+ end
158
+ parts << "#{count(state[:failed_files], "file")} failed" if state[:failed_files].positive?
159
+
160
+ @stdout.puts(parts.join(", "))
161
+ end
162
+
163
+ def corrected_summary(state)
164
+ return "0 corrected" if state[:corrected].zero?
165
+
166
+ "#{state[:corrected]} corrected in #{count(state[:corrected_files], "file")}"
167
+ end
168
+
169
+ def remaining_summary(number)
170
+ "#{count(number, "issue")} #{(number == 1) ? "remains" : "remain"}"
171
+ end
172
+
173
+ def count(number, noun)
174
+ "#{number} #{noun}#{"s" unless number == 1}"
175
+ end
176
+
177
+ def print_findings(result, display_path, findings: result.findings)
178
+ findings.each do |finding|
179
+ line, column = result.document.line_and_column(finding.range.start_offset)
180
+ @stdout.puts("#{display_path}:#{line}:#{column}: #{finding.rule_id} #{finding.message}")
181
+ end
182
+ end
183
+
184
+ def print_diff(result, display_path, options)
185
+ return unless result.changed?
186
+
187
+ output = @diff.call(result.source, result.formatted_source, path: display_path)
188
+ @stdout.write(colorize_diff(output, options))
189
+ end
190
+
191
+ def colorize_diff(output, options)
192
+ return output unless options[:color] && @stdout.respond_to?(:tty?) && @stdout.tty?
193
+
194
+ output.lines.map do |line|
195
+ case line
196
+ when /\A\+(?!\+\+)/ then "\e[32m#{line}\e[0m"
197
+ when /\A-(?!--)/ then "\e[31m#{line}\e[0m"
198
+ when /\A@@/ then "\e[36m#{line}\e[0m"
199
+ else line
200
+ end
201
+ end.join
202
+ end
203
+
204
+ def display_path(path)
205
+ relative = Pathname(File.expand_path(path)).relative_path_from(Pathname(@cwd)).to_s
206
+ return File.expand_path(path) if relative == ".." || relative.start_with?("../")
207
+
208
+ relative
209
+ rescue ArgumentError
210
+ File.expand_path(path)
211
+ end
212
+
213
+ def warn_message(message)
214
+ @stderr.puts("warning: #{message}")
215
+ end
216
+
217
+ def print_help(parser)
218
+ @stdout.puts(parser)
219
+ 0
220
+ end
221
+
222
+ def print_version
223
+ @stdout.puts(Yamlfmt::VERSION)
224
+ 0
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ module Yamlfmt
6
+ class Config
7
+ FILE_NAME = ".yamlfmt.yml"
8
+ TOP_LEVEL_KEYS = %w[rules exclude].freeze
9
+
10
+ attr_reader :exclude, :warnings, :path
11
+
12
+ def self.load(cwd: Dir.pwd)
13
+ path = File.join(File.expand_path(cwd), FILE_NAME)
14
+ return new({}, path:) unless File.file?(path)
15
+
16
+ source = File.binread(path).force_encoding(Encoding::UTF_8)
17
+ raise ConfigError, "#{path}: configuration must be valid UTF-8" unless source.valid_encoding?
18
+
19
+ data = Psych.safe_load(source, filename: path, aliases: false) || {}
20
+ new(data, path:)
21
+ rescue Psych::Exception, SystemCallError => error
22
+ raise ConfigError, "#{path}: #{error.message}"
23
+ end
24
+
25
+ def initialize(data, path: File.join(Dir.pwd, FILE_NAME))
26
+ raise ConfigError, "configuration root must be a mapping" unless data.is_a?(Hash)
27
+
28
+ @path = path
29
+ @warnings = []
30
+ warn_unknown_top_level_keys(data)
31
+ @rule_options = parse_rules(fetch(data, "rules", {})).freeze
32
+ @exclude = parse_exclude(fetch(data, "exclude", [])).freeze
33
+ @warnings.freeze
34
+ end
35
+
36
+ def options_for(rule_class)
37
+ value = @rule_options.fetch(rule_class.rule_id, {})
38
+ return nil if value == false
39
+
40
+ value
41
+ end
42
+
43
+ private
44
+
45
+ def parse_rules(rules)
46
+ raise ConfigError, "rules must be a mapping" unless rules.is_a?(Hash)
47
+
48
+ rules.each_with_object({}) do |(id, value), result|
49
+ id = id.to_s
50
+ unless Rule::Registry.ids.include?(id)
51
+ @warnings << "unknown rule: #{id}"
52
+ next
53
+ end
54
+
55
+ result[id] = parse_rule_value(Rule::Registry.fetch(id), value)
56
+ end
57
+ end
58
+
59
+ def parse_rule_value(rule_class, value)
60
+ return false if value == false
61
+ return {} if value == true
62
+ raise ConfigError, "#{rule_class.rule_id} must be true, false, or a mapping" unless value.is_a?(Hash)
63
+
64
+ defaults = rule_class.default_config
65
+ value.each_with_object({}) do |(key, option_value), options|
66
+ unless key.is_a?(String) || key.is_a?(Symbol)
67
+ raise ConfigError, "option names for #{rule_class.rule_id} must be strings or symbols"
68
+ end
69
+
70
+ key = key.to_sym
71
+ unless defaults.key?(key)
72
+ @warnings << "unknown option for #{rule_class.rule_id}: #{key}"
73
+ next
74
+ end
75
+
76
+ options[key] = option_value
77
+ end
78
+ end
79
+
80
+ def parse_exclude(exclude)
81
+ raise ConfigError, "exclude must be a list" unless exclude.is_a?(Array)
82
+
83
+ exclude.map do |pattern|
84
+ raise ConfigError, "exclude entries must be non-empty strings" unless pattern.is_a?(String) && !pattern.empty?
85
+ raise ConfigError, "exclude entries must be relative paths" if pattern.start_with?("/")
86
+ raise ConfigError, "exclude negation is not supported" if pattern.start_with?("!")
87
+
88
+ normalized = pattern.delete_prefix("./").delete_suffix("/")
89
+ components = normalized.split("/")
90
+ if normalized.empty? || components.include?("..")
91
+ raise ConfigError, "exclude entries must stay within the current directory"
92
+ end
93
+
94
+ normalized
95
+ end
96
+ end
97
+
98
+ def warn_unknown_top_level_keys(data)
99
+ data.each_key do |key|
100
+ @warnings << "unknown configuration key: #{key}" unless TOP_LEVEL_KEYS.include?(key.to_s)
101
+ end
102
+ end
103
+
104
+ def fetch(hash, key, default)
105
+ return hash[key] if hash.key?(key)
106
+ return hash[key.to_sym] if hash.key?(key.to_sym)
107
+
108
+ default
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamlfmt
4
+ class Corrector
5
+ def call(source, findings)
6
+ edits = normalize(findings)
7
+ validate_ranges!(source, edits)
8
+
9
+ edits.sort_by { |edit| [edit.range.start_offset, edit.range.end_offset] }.reverse_each.reduce(source.dup) do |result, edit|
10
+ before = result.byteslice(0, edit.range.start_offset)
11
+ after = result.byteslice(edit.range.end_offset, result.bytesize - edit.range.end_offset)
12
+ "#{before}#{edit.replacement}#{after}"
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def normalize(findings)
19
+ pairs = findings.filter_map { |finding| [finding, finding.edit] if finding.edit }
20
+ unique_pairs = pairs.uniq { |_, edit| [edit.range, edit.replacement] }
21
+ redundant = {}
22
+
23
+ sorted_pairs = unique_pairs.sort_by { |_, edit| [edit.range.start_offset, edit.range.end_offset] }
24
+
25
+ sorted_pairs.each_with_index do |left, index|
26
+ left_finding, left_edit = left
27
+ ((index + 1)...sorted_pairs.length).each do |right_index|
28
+ right = sorted_pairs[right_index]
29
+ right_finding, right_edit = right
30
+ break if past_overlap_window?(left_edit, right_edit)
31
+
32
+ next unless left_edit.range.overlaps?(right_edit.range)
33
+
34
+ if redundant_deletion?(left_edit, right_edit)
35
+ redundant[right.object_id] = true
36
+ elsif redundant_deletion?(right_edit, left_edit)
37
+ redundant[left.object_id] = true
38
+ else
39
+ raise ConflictError, [left_finding, right_finding]
40
+ end
41
+ end
42
+ end
43
+
44
+ unique_pairs.reject { |pair| redundant[pair.object_id] }.map(&:last)
45
+ end
46
+
47
+ def past_overlap_window?(left_edit, right_edit)
48
+ left_range = left_edit.range
49
+ right_range = right_edit.range
50
+
51
+ if left_range.empty?
52
+ right_range.start_offset > left_range.start_offset
53
+ else
54
+ right_range.start_offset >= left_range.end_offset
55
+ end
56
+ end
57
+
58
+ def redundant_deletion?(outer, inner)
59
+ outer.replacement.empty? && inner.replacement.empty? && outer.range.cover?(inner.range)
60
+ end
61
+
62
+ def validate_ranges!(source, edits)
63
+ invalid = edits.find { |edit| edit.range.end_offset > source.bytesize }
64
+ raise ArgumentError, "edit range is outside the source" if invalid
65
+ end
66
+ end
67
+ end