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 +7 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/LICENSE +21 -0
- data/README.md +207 -0
- data/Rakefile +10 -0
- data/exe/yamlfmt +5 -0
- data/lib/yamlfmt/cli.rb +227 -0
- data/lib/yamlfmt/config.rb +111 -0
- data/lib/yamlfmt/corrector.rb +67 -0
- data/lib/yamlfmt/document.rb +241 -0
- data/lib/yamlfmt/edit.rb +12 -0
- data/lib/yamlfmt/errors.rb +21 -0
- data/lib/yamlfmt/exclude_matcher.rb +65 -0
- data/lib/yamlfmt/file_finder.rb +75 -0
- data/lib/yamlfmt/finding.rb +16 -0
- data/lib/yamlfmt/processor.rb +39 -0
- data/lib/yamlfmt/rule/ast_based.rb +57 -0
- data/lib/yamlfmt/rule/base.rb +54 -0
- data/lib/yamlfmt/rule/blank_lines.rb +66 -0
- data/lib/yamlfmt/rule/final_newline.rb +46 -0
- data/lib/yamlfmt/rule/line_based.rb +48 -0
- data/lib/yamlfmt/rule/registry.rb +39 -0
- data/lib/yamlfmt/rule/trailing_whitespace.rb +59 -0
- data/lib/yamlfmt/rule/unnecessary_quotes.rb +50 -0
- data/lib/yamlfmt/rule_plan.rb +12 -0
- data/lib/yamlfmt/safety_validator.rb +72 -0
- data/lib/yamlfmt/source_range.rb +38 -0
- data/lib/yamlfmt/unified_diff.rb +127 -0
- data/lib/yamlfmt/version.rb +5 -0
- data/lib/yamlfmt.rb +28 -0
- metadata +112 -0
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
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
data/exe/yamlfmt
ADDED
data/lib/yamlfmt/cli.rb
ADDED
|
@@ -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
|